{
  "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": 811,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:50.815Z",
                      "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": 206,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:51.830Z",
                      "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": 73,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.076Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-a984cd7f10c08f210a94",
              "file": "tour_pg.spec.js",
              "line": 42,
              "column": 3
            },
            {
              "title": "HTML carries current tour-pg SW version",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 26,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.172Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-f8b419d6030eb8f1dfd4",
              "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 50,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.211Z",
                      "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": 776,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:15.047Z",
                      "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": 225,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.043Z",
                      "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": 63,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.297Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8620fb99767dbe91366c",
              "file": "tour_pg.spec.js",
              "line": 42,
              "column": 3
            },
            {
              "title": "HTML carries current tour-pg SW version",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 40,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.374Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-fe54eff155321b030cdd",
              "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": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 20,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.434Z",
                      "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 3,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.276Z",
                      "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 6,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.336Z",
                      "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 7,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.353Z",
                      "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 9,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.374Z",
                      "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": 0,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 4,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.405Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8391bbee5cdba382f02e",
              "file": "tour_pg.spec.js",
              "line": 118,
              "column": 3
            },
            {
              "title": "SortableJS + SheetJS CDN loaded",
              "ok": false,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 0,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 22,
                      "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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:126:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 126
                        },
                        "snippet": "  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 126
                          },
                          "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {\n    at /var/www/360lm/tests/tour_pg.spec.js:126:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:52.423Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--b4648-rtableJS-SheetJS-CDN-loaded-android-chrome/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 126
                      }
                    },
                    {
                      "workerIndex": 1,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 32,
                      "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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:126:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 126
                        },
                        "snippet": "  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 126
                          },
                          "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {\n    at /var/www/360lm/tests/tour_pg.spec.js:126:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 1,
                      "startTime": "2026-06-23T13:01:53.478Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--b4648-rtableJS-SheetJS-CDN-loaded-android-chrome-retry1/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 126
                      }
                    }
                  ],
                  "status": "unexpected"
                }
              ],
              "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": 20,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:54.659Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-6a6396b44064bf473745",
              "file": "tour_pg.spec.js",
              "line": 129,
              "column": 3
            },
            {
              "title": "manual planner screen + entry button + back/pill present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 9,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:54.846Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-7530a846c74c42250e96",
              "file": "tour_pg.spec.js",
              "line": 139,
              "column": 3
            },
            {
              "title": "shared refactor functions present (S0)",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 20,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:54.880Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-a327237aba39686d70f3",
              "file": "tour_pg.spec.js",
              "line": 150,
              "column": 3
            },
            {
              "title": "two main screens declared",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 10,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.467Z",
                      "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": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 5,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.527Z",
                      "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": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 8,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.542Z",
                      "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": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 4,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.561Z",
                      "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": 3,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 2,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.576Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-6dbbc2f246d66bdb483d",
              "file": "tour_pg.spec.js",
              "line": 118,
              "column": 3
            },
            {
              "title": "SortableJS + SheetJS CDN loaded",
              "ok": false,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 3,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 22,
                      "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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:126:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 126
                        },
                        "snippet": "  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 126
                          },
                          "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {\n    at /var/www/360lm/tests/tour_pg.spec.js:126:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:16.587Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--b4648-rtableJS-SheetJS-CDN-loaded-desktop-chrome/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 126
                      }
                    },
                    {
                      "workerIndex": 4,
                      "parallelIndex": 0,
                      "status": "failed",
                      "duration": 40,
                      "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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:126:18",
                        "location": {
                          "file": "/var/www/360lm/tests/tour_pg.spec.js",
                          "column": 18,
                          "line": 126
                        },
                        "snippet": "  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {"
                      },
                      "errors": [
                        {
                          "location": {
                            "file": "/var/www/360lm/tests/tour_pg.spec.js",
                            "column": 18,
                            "line": 126
                          },
                          "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\"sheetjs\"\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=\\\"/tour-pg/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: native PAGE scroll (root scroller = smooth on desktop + mobile).\u001b[39m\n\u001b[31m   Header is already sticky; the compact totals/Share/Re-optim bar sticks just\u001b[39m\n\u001b[31m   below it so the key info + actions stay visible while the whole list scrolls.\u001b[39m\n\u001b[31m   No nested overflow container (that broke momentum scroll on mobile and clipped\u001b[39m\n\u001b[31m   the visible stop count on desktop). --hdr-h is the sticky offset for the bar. */\u001b[39m\n\u001b[31m:root{--hdr-h:56px}\u001b[39m\n\u001b[31m.hdr{min-height:var(--hdr-h)}·\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.progress-bar.indeterminate{width:100%!important;transition:none;background:linear-gradient(90deg,var(--accent) 0%,var(--g) 40%,var(--accent) 100%);background-size:200% 100%;animation:barShimmer 1.4s linear infinite}\u001b[39m\n\u001b[31m@keyframes barShimmer{from{background-position:100% 0}to{background-position:-100% 0}}·\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/* Extra right inset on the stops list only (per user) so each tile is a touch\u001b[39m\n\u001b[31m   narrower on the right; the full-width km/Share/Re-optim bar above is unaffected. */\u001b[39m\n\u001b[31m.tiles{display:flex;flex-direction:column;gap:6px;padding-right:20px}\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.tile .fixloc{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:rgba(255,255,255,.05);font-size:15px}\u001b[39m\n\u001b[31m.tile .fixloc:active{background:rgba(14,165,233,.25);border-color:var(--accent)}\u001b[39m\n\u001b[31m.tile.excluded .fixloc{opacity:.5}\u001b[39m\n\u001b[31m/* geocode-confidence flags */\u001b[39m\n\u001b[31m.tile.geowarn{border-left:3px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag{margin-top:3px;font-size:10px;line-height:1.35;cursor:pointer;border-radius:4px;padding:3px 6px}\u001b[39m\n\u001b[31m.tile .geo-flag u{text-decoration:underline;white-space:nowrap}\u001b[39m\n\u001b[31m.tile .geo-flag.loud{color:#fde68a;background:rgba(234,179,8,.14);font-weight:700;border-left:2px solid var(--y)}\u001b[39m\n\u001b[31m.tile .geo-flag.soft{color:var(--muted);background:rgba(255,255,255,.04);font-weight:600}\u001b[39m\n\u001b[31m.tile.excluded .geo-flag{display:none}\u001b[39m\n\u001b[31m/* editor flag summary banner */\u001b[39m\n\u001b[31m.flag-summary{font-size:11px;font-weight:700;border-radius:6px;padding:7px 10px;margin-bottom:2px;display:none}\u001b[39m\n\u001b[31m.flag-summary.show{display:block}\u001b[39m\n\u001b[31m.flag-summary .loud{color:#fde68a}\u001b[39m\n\u001b[31m.flag-summary .soft{color:var(--muted);font-weight:600}·\u001b[39m\n\u001b[31m/* ── Fix-location modal (manual geocode correction) ── */\u001b[39m\n\u001b[31m.fixloc-name{font-size:13px;font-weight:700;margin-bottom:8px;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-name small{display:block;font-weight:500;color:var(--muted);font-size:11px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-search{display:flex;gap:6px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-search input{flex:1;background:#0b1220;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:9px 11px;font-size:13px;font-family:inherit;outline:none}\u001b[39m\n\u001b[31m.fixloc-search input:focus{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-search button{flex-shrink:0;background:var(--accent);color:#fff;border-radius:6px;padding:0 14px;font-size:14px;font-weight:800}\u001b[39m\n\u001b[31m.fixloc-maphdr{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:5px}\u001b[39m\n\u001b[31m.fixloc-maphdr #fixloc-coords{font-family:monospace;font-size:11px}\u001b[39m\n\u001b[31m.fixloc-layer{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:12px;padding:4px 10px;font-size:11px;font-weight:700;color:var(--text)}\u001b[39m\n\u001b[31m.fixloc-layer.on{background:var(--accent);border-color:var(--accent);color:#fff}\u001b[39m\n\u001b[31m#fixloc-map{height:300px;width:100%;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;overflow:hidden}\u001b[39m\n\u001b[31m.fixloc-hint{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}\u001b[39m\n\u001b[31m.fixloc-candidates{background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.35);border-radius:6px;padding:8px;margin-bottom:8px}\u001b[39m\n\u001b[31m.fixloc-candidates .ttl{font-size:11px;font-weight:700;color:#fde68a;margin-bottom:6px}\u001b[39m\n\u001b[31m.fixloc-candidates .opt{display:flex;gap:6px;width:100%;text-align:left;background:#0b1220;border:1px solid var(--border);border-radius:5px;padding:7px 9px;margin-top:5px;font-size:11.5px;color:var(--text);align-items:center}\u001b[39m\n\u001b[31m.fixloc-candidates .opt:active{border-color:var(--accent)}\u001b[39m\n\u001b[31m.fixloc-candidates .opt b{font-weight:800}\u001b[39m\n\u001b[31m.fixloc-candidates .opt small{display:block;color:var(--muted);font-size:10px;margin-top:1px}\u001b[39m\n\u001b[31m.fixloc-pin{background:transparent;border:none}\u001b[39m\n\u001b[31m.fixloc-pin > div{font-size:30px;line-height:1;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}·\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;position:sticky;top:calc(var(--hdr-h) - 2px);z-index:9}\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/* ── Group editor ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.ge-table{width:100%;border-collapse:collapse;font-size:12px}\u001b[39m\n\u001b[31m.ge-table th{padding:6px 10px;text-align:left;font-weight:600;color:var(--muted);font-size:11px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-table td{padding:7px 10px;border-bottom:1px solid var(--border)}\u001b[39m\n\u001b[31m.ge-select{background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:11px;max-width:160px}\u001b[39m\n\u001b[31m/* ── Batch canvas ─────────────────────────────────────────────────────── */\u001b[39m\n\u001b[31m.canvas-lanes{display:flex;overflow-x:auto;gap:12px;padding:12px;flex:1;align-items:flex-start;min-height:0}\u001b[39m\n\u001b[31m.canvas-lane{min-width:280px;max-width:320px;flex-shrink:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}\u001b[39m\n\u001b[31m.canvas-lane-hdr{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-lane-map{height:200px;flex-shrink:0}\u001b[39m\n\u001b[31m.canvas-stop-list{overflow-y:auto;padding:6px;min-height:60px}\u001b[39m\n\u001b[31m.canvas-stop-card{background:var(--card2);border:1px solid var(--border);border-radius:7px;padding:8px 10px;margin-bottom:5px;font-size:12px;cursor:grab;user-select:none;transition:opacity .15s;display:flex;align-items:baseline;gap:4px}\u001b[39m\n\u001b[31m.canvas-stop-card.drag-over{border-color:var(--accent);background:rgba(99,102,241,.1)}\u001b[39m\n\u001b[31m.canvas-drop-zone{border:2px dashed var(--border);border-radius:7px;padding:12px;text-align:center;font-size:11px;color:var(--muted);min-height:50px;display:flex;align-items:center;justify-content:center}\u001b[39m\n\u001b[31m.canvas-drop-zone.drag-over{border-color:var(--accent);color:var(--accent)}·\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/* ── history & upload-preview screens ── */\u001b[39m\n\u001b[31m.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px}\u001b[39m\n\u001b[31m.tab-btn{flex:1;padding:10px;font-size:12px;font-weight:700;border-bottom:2px solid transparent;color:var(--muted);text-align:center;cursor:pointer;background:none}\u001b[39m\n\u001b[31m.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}\u001b[39m\n\u001b[31m.hist-item{background:var(--card);border:1px solid var(--border);border-radius:var(--rads);padding:10px 12px;display:flex;align-items:center;gap:10px;margin-bottom:6px}\u001b[39m\n\u001b[31m.hist-meta{flex:1;min-width:0}\u001b[39m\n\u001b[31m.hist-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-sub{font-size:11px;color:var(--muted);margin-top:2px}\u001b[39m\n\u001b[31m.hist-actions{display:flex;gap:6px;flex-shrink:0}\u001b[39m\n\u001b[31m.hist-btn{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:700;color:var(--text);cursor:pointer;white-space:nowrap}\u001b[39m\n\u001b[31m.hist-btn.danger{color:#fca5a5;border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.08)}\u001b[39m\n\u001b[31m.hist-btn:active{background:rgba(255,255,255,.16)}\u001b[39m\n\u001b[31m/* stops grid in upload-preview */\u001b[39m\n\u001b[31m.stops-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:6px}\u001b[39m\n\u001b[31m.stops-grid{border-collapse:collapse;width:100%;min-width:700px;font-size:11px}\u001b[39m\n\u001b[31m.stops-grid 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)}\u001b[39m\n\u001b[31m.stops-grid td{padding:6px 8px;vertical-align:middle;border-top:1px solid var(--border)}\u001b[39m\n\u001b[31m.stops-grid .geo-ok{color:var(--g);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-warn{color:var(--y);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .geo-err{color:var(--r);font-weight:700}\u001b[39m\n\u001b[31m.stops-grid .coord{font-family:monospace;font-size:9.5px;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\\\" id=\\\"btn-resume\\\" onclick=\\\"showScreen('s-editor')\\\" style=\\\"display:none;background:var(--accent);color:#fff;border:none\\\">▶ Tour</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-new\\\" onclick=\\\"confirmBack()\\\" style=\\\"display:none\\\">＋ New</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"openHistory()\\\">📂</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    <button class=\\\"btn btn-p\\\" onclick=\\\"enterManual()\\\" style=\\\"background:linear-gradient(135deg,#0ea5e9,#22c55e)\\\">🧭 Plan a tour manually (no Excel)</button>\u001b[39m\n\u001b[31m    <div style=\\\"text-align:center;font-size:11px;color:var(--muted);margin:-4px 0 2px\\\">— or upload an Excel below —</div>\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=\\\"360 Degree Logical Marketing, Mohali office\\\" 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 id=\\\"opt-wait\\\" style=\\\"display:none;margin-top:10px;align-items:center;justify-content:center;gap:10px;padding:6px 0 2px\\\">\u001b[39m\n\u001b[31m        <span class=\\\"spinner\\\" style=\\\"width:20px;height:20px;border-width:3px;flex-shrink:0\\\"></span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:12px;color:var(--muted)\\\">Solving route<span id=\\\"opt-dots\\\"></span></span>\u001b[39m\n\u001b[31m        <span id=\\\"opt-elapsed\\\" style=\\\"font-size:11px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:20px;padding:1px 8px\\\">0s</span>\u001b[39m\n\u001b[31m      </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 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=\\\"editorBack()\\\">◀</button>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-batch-back\\\" onclick=\\\"batchBack()\\\" style=\\\"display:none;font-size:11px\\\">📦 Batch</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"editor-title\\\">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=\\\"saveTourToDB()\\\" title=\\\"Save tour to account\\\">💾</button>\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=\\\"body\\\" style=\\\"padding-bottom:90px\\\">·\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\\\" id=\\\"stops-title\\\" style=\\\"margin-top:4px\\\">Stops (drag ≡ to reorder)</div>\u001b[39m\n\u001b[31m    <div class=\\\"flag-summary\\\" id=\\\"flag-summary\\\"></div>·\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<!-- ── SCREEN: MANUAL PLANNER ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-manual\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"manualBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🧭 Plan a tour</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Add stops on the map</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-3\\\" style=\\\"display:none\\\"></span>\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\\\" style=\\\"padding-bottom:90px\\\">·\u001b[39m\n\u001b[31m    <div class=\\\"form-card\\\">\u001b[39m\n\u001b[31m      <div class=\\\"section-title\\\">Start point</div>\u001b[39m\n\u001b[31m      <div class=\\\"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=\\\"manual-origin-val\\\">—</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"manualPickOrigin()\\\">✎ Change</button>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"manualUseMyLocation()\\\" id=\\\"manual-gps\\\">📍 Use my current location</button>\u001b[39m\n\u001b[31m      <div class=\\\"toggle\\\">\u001b[39m\n\u001b[31m        <input type=\\\"checkbox\\\" id=\\\"manual-roundtrip\\\" checked onchange=\\\"manualToggleRoundtrip()\\\">\u001b[39m\n\u001b[31m        <label for=\\\"manual-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=\\\"manual-dest-wrap\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m        <div class=\\\"origin-pill\\\" style=\\\"background:linear-gradient(135deg,#2a1f0c,#1c1408);border-color:#a16207\\\">\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\\\">Final destination</div>\u001b[39m\n\u001b[31m            <div class=\\\"val\\\" id=\\\"manual-dest-val\\\">— tap Set —</div>\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <button onclick=\\\"manualPickDest()\\\">✎ Set</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=\\\"section-title\\\" style=\\\"margin-top:4px\\\">Destinations <span id=\\\"manual-count\\\" style=\\\"color:var(--accent);font-weight:800\\\"></span></div>\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"manualAddStop()\\\">➕ Add destination</button>\u001b[39m\n\u001b[31m    <div id=\\\"manual-empty\\\" class=\\\"empty\\\" style=\\\"padding:18px 12px\\\">No stops yet — tap <b>➕ Add destination</b> to drop pins one by one.</div>\u001b[39m\n\u001b[31m    <div id=\\\"manual-stops\\\" class=\\\"tiles\\\" style=\\\"padding-right:0\\\"></div>·\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" id=\\\"manual-find\\\" onclick=\\\"manualFindOptimized()\\\" disabled style=\\\"margin-top:8px\\\">✓ Find optimized path</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: HISTORY ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-history\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📂 History</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Your uploads &amp; saved tours</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <span class=\\\"user-pill\\\" id=\\\"user-pill-4\\\" style=\\\"display:none\\\"></span>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div class=\\\"tab-bar\\\">\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn active\\\" id=\\\"tab-uploads\\\" onclick=\\\"histShowTab('uploads')\\\">📊 Uploads</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-tours\\\" onclick=\\\"histShowTab('tours')\\\">🗺 Tours</button>\u001b[39m\n\u001b[31m      <button class=\\\"tab-btn\\\" id=\\\"tab-batches\\\" onclick=\\\"histShowTab('batches')\\\">📦 Batches</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-uploads-pane\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-uploads-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-tours-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-tours-list\\\"><div class=\\\"empty\\\">Loading…</div></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"hist-batches-pane\\\" style=\\\"display:none\\\">\u001b[39m\n\u001b[31m      <div id=\\\"hist-batches-list\\\"><div class=\\\"empty\\\">Loading…</div></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: UPLOAD PREVIEW (geocoded stops grid) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-upload-preview\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"preview-title\\\">Upload Preview</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"preview-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-optimise-upload\\\" onclick=\\\"optimiseLoadedUpload()\\\" style=\\\"background:var(--accent);color:#fff;border:none\\\" disabled>⚡ Optimise</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"upload-preview-grid\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: SHEET SELECT (multi-sheet Excel) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-sheet-select\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>📋 Select Sheets</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Choose which tabs to plan</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"font-size:12px;color:var(--muted);margin-bottom:10px\\\">This file has multiple sheets. Select the ones to plan tours for — the master sheet (all stores combined) is unchecked by default.</div>\u001b[39m\n\u001b[31m    <div id=\\\"sheet-check-list\\\" style=\\\"display:flex;flex-direction:column;gap:8px;margin-bottom:16px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-sheet\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmSheetSelect()\\\">Continue → Plan Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUPING (single-sheet — pick column) ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-grouping\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗂 Group Stores</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\">Split into tours by column</div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.4px\\\">Group stores by</div>\u001b[39m\n\u001b[31m      <select id=\\\"sel-group-col\\\" onchange=\\\"updateGroupingPreview()\\\" style=\\\"width:100%;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px\\\"></select>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted);margin-top:8px\\\">Stores per tour cap (warning only): <input type=\\\"number\\\" id=\\\"inp-cap\\\" value=\\\"20\\\" min=\\\"1\\\" max=\\\"200\\\" oninput=\\\"updateGroupingPreview()\\\" style=\\\"width:60px;background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:4px 6px;color:var(--fg);font-size:12px;margin-left:4px\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"grouping-preview\\\" style=\\\"margin-bottom:14px\\\"></div>\u001b[39m\n\u001b[31m    <input type=\\\"text\\\" id=\\\"inp-batch-name-group\\\" placeholder=\\\"Batch / campaign name\\\" style=\\\"width:100%;box-sizing:border-box;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--fg);font-size:13px;margin-bottom:12px\\\">\u001b[39m\n\u001b[31m    <button class=\\\"btn btn-p\\\" onclick=\\\"confirmGrouping()\\\">Confirm → Review Tours</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH REVIEW ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-review\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1 id=\\\"batch-review-title\\\">📦 Tour Batch</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"batch-review-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" id=\\\"btn-canvas\\\" onclick=\\\"openBatchCanvas()\\\" style=\\\"display:none;color:var(--accent)\\\">📊 Canvas</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\">\u001b[39m\n\u001b[31m    <div id=\\\"batch-group-cards\\\" style=\\\"display:flex;flex-direction:column;gap:10px\\\"></div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: GROUP EDITOR ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-group-editor\\\" class=\\\"scr\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>✏️ Edit Groups</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"ge-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"confirmGroupEditor()\\\" style=\\\"color:var(--accent);font-weight:800\\\">Done →</button>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"body\\\" id=\\\"ge-body\\\"></div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── SCREEN: BATCH CANVAS ── -->\u001b[39m\n\u001b[31m<div id=\\\"s-batch-canvas\\\" class=\\\"scr\\\" style=\\\"flex-direction:column\\\">\u001b[39m\n\u001b[31m  <div class=\\\"hdr\\\">\u001b[39m\n\u001b[31m    <button class=\\\"hdr-btn\\\" onclick=\\\"goBack()\\\">‹ Back</button>\u001b[39m\n\u001b[31m    <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m      <h1>🗺 Batch Canvas</h1>\u001b[39m\n\u001b[31m      <div class=\\\"hdr-sub\\\" id=\\\"canvas-sub\\\"></div>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m  <div class=\\\"canvas-lanes\\\" id=\\\"canvas-lanes\\\"></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      <button class=\\\"btn btn-s\\\" onclick=\\\"saveTourToDB()\\\">🗺 Save tour plan to account</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m  </div>\u001b[39m\n\u001b[31m</div>·\u001b[39m\n\u001b[31m<!-- ── MODAL: FIX LOCATION (manual geocode correction) ── -->\u001b[39m\n\u001b[31m<div class=\\\"modal\\\" id=\\\"modal-fixloc\\\">\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\\\">📍 Fix location</div>\u001b[39m\n\u001b[31m      <button class=\\\"modal-close\\\" onclick=\\\"closeFixLoc()\\\">×</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-name\\\" id=\\\"fixloc-name\\\">—</div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-candidates\\\" id=\\\"fixloc-candidates\\\" style=\\\"display:none\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-search\\\">\u001b[39m\n\u001b[31m      <input type=\\\"text\\\" id=\\\"fixloc-search-inp\\\" placeholder=\\\"Search place, shop or landmark…\\\"\u001b[39m\n\u001b[31m             onkeydown=\\\"if(event.key==='Enter'){event.preventDefault();fixLocSearch();}\\\">\u001b[39m\n\u001b[31m      <button onclick=\\\"fixLocSearch()\\\" title=\\\"Search\\\">🔍</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-maphdr\\\">\u001b[39m\n\u001b[31m      <span id=\\\"fixloc-coords\\\">—</span>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc-layer\\\" id=\\\"fixloc-layer\\\" onclick=\\\"toggleFixLayer()\\\">🛰 Satellite</button>\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <div id=\\\"fixloc-map\\\"></div>\u001b[39m\n\u001b[31m    <div class=\\\"fixloc-hint\\\">Drag the 📍 pin (or tap the map) to the exact spot. Use search to jump to the right area first — satellite view helps you recognise the building.</div>\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;gap:8px;margin-top:12px\\\">\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-s\\\" onclick=\\\"closeFixLoc()\\\" style=\\\"flex:1\\\">Cancel</button>\u001b[39m\n\u001b[31m      <button class=\\\"btn btn-p\\\" onclick=\\\"saveFixLoc()\\\" style=\\\"flex:2\\\">✓ Use this location</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-v42';\u001b[39m\n\u001b[31mconst PROXY     = '/tour-pg-proxy';·\u001b[39m\n\u001b[31m// Default origin: GEOCODE QUERY (the exact Plus Code) is decoupled from the\u001b[39m\n\u001b[31m// DISPLAY LABEL shown to the user. The Plus Code resolves to the precise office\u001b[39m\n\u001b[31m// coords (30.6739375, 76.7449375); the label is what we show in the input,\u001b[39m\n\u001b[31m// origin pill, map tooltip and share text.\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_QUERY = 'MPFV+HX Sahibzada Ajit Singh Nagar, Punjab';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LABEL = '360 Degree Logical Marketing, Mohali office';\u001b[39m\n\u001b[31mconst DEFAULT_ORIGIN_LOC   = { lat: 30.6739375, lng: 76.7449375 };  // resolved Plus Code (skips a geocode call)\u001b[39m\n\u001b[31m// What to show for the origin (falls back to the geocode text for old saved\u001b[39m\n\u001b[31m// tours / custom origins that have no separate label).\u001b[39m\n\u001b[31mfunction originLabel() { return (state.origin && (state.origin.label || state.origin.text)) || 'origin'; }·\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: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, 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-3').innerHTML = userText;\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  document.getElementById('user-pill-3').style.display = '';\u001b[39m\n\u001b[31m  document.getElementById('user-pill-4').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  updateResumeBtn();·\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[31m// Per-endpoint network timeout so a HUNG request (flaky mobile link — the request\u001b[39m\n\u001b[31m// stalls and never returns) fails fast instead of freezing the geocode loop\u001b[39m\n\u001b[31m// forever. This is the fix for \\\"stuck during geocode\\\": before, fetch had no\u001b[39m\n\u001b[31m// timeout, so one stalled call on a bad connection blocked everything. The\u001b[39m\n\u001b[31m// dual-search (v18) doubled the number of fetches, doubling the odds of hitting\u001b[39m\n\u001b[31m// such a stall — hence the correlation, though the cross-check itself is fine.\u001b[39m\n\u001b[31m//   • short endpoints (geocode/reverse): 12s + one automatic retry (rides out a\u001b[39m\n\u001b[31m//     transient drop), then the caller's existing catch marks the stop failed.\u001b[39m\n\u001b[31m//   • slow path (optimize/matrix/route): 120s ceiling (measured ~39s for 49\u001b[39m\n\u001b[31m//     stops), no retry.\u001b[39m\n\u001b[31m// Success / 401 / non-2xx handling is byte-identical to before — only the\u001b[39m\n\u001b[31m// timeout+retry wrapper is added; nothing in the existing flow changes.\u001b[39m\n\u001b[31masync function api(path, body) {\u001b[39m\n\u001b[31m  const isLong    = /\\\\/(optimize|matrix|route)\\\\b/.test(path);\u001b[39m\n\u001b[31m  const timeoutMs = isLong ? 120000 : 12000;\u001b[39m\n\u001b[31m  const attempts  = isLong ? 1 : 2;\u001b[39m\n\u001b[31m  let netErr;\u001b[39m\n\u001b[31m  for (let i = 0; i < attempts; i++) {\u001b[39m\n\u001b[31m    const ac  = new AbortController();\u001b[39m\n\u001b[31m    const tid = setTimeout(() => ac.abort(), timeoutMs);\u001b[39m\n\u001b[31m    let r;\u001b[39m\n\u001b[31m    try {\u001b[39m\n\u001b[31m      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        signal: ac.signal,\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    } catch (e) {\u001b[39m\n\u001b[31m      clearTimeout(tid);\u001b[39m\n\u001b[31m      netErr = (e && e.name === 'AbortError')\u001b[39m\n\u001b[31m        ? new Error(`${path}: timed out after ${timeoutMs / 1000}s`)\u001b[39m\n\u001b[31m        : new Error(`${path}: network error`);\u001b[39m\n\u001b[31m      if (i < attempts - 1) { await sleep(500); continue; }   // retry a transient drop\u001b[39m\n\u001b[31m      throw netErr;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    clearTimeout(tid);\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  throw netErr;\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[31mlet _rawHeaders    = null;   // original header strings from the Excel (safe for detectColumns)\u001b[39m\n\u001b[31mlet _currentFilename = '';       // filename of the most recently uploaded Excel\u001b[39m\n\u001b[31mlet _loadedFromUpload = false;   // true when stops were loaded from a saved DB upload\u001b[39m\n\u001b[31mlet _loadedUploadFilename = '';  // filename of the loaded upload·\u001b[39m\n\u001b[31m// ── Batch / multi-tour state ─────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _batchId        = null;   // DB id of current batch (null until saved)\u001b[39m\n\u001b[31mlet _batchName      = '';     // editable batch name\u001b[39m\n\u001b[31mlet _batchGroups    = [];     // [{localId,dbId,name,rawRows,rawHeaders,parsedCols,stops,planned,capWarn}]\u001b[39m\n\u001b[31mlet _batchGroupIdx  = null;   // index of group currently open in editor\u001b[39m\n\u001b[31mlet _groupCol       = null;   // column used for grouping (Case 1 single-sheet)\u001b[39m\n\u001b[31mlet _workbook       = null;   // XLSX workbook reference (for multi-sheet)\u001b[39m\n\u001b[31mlet _uploadFilename = '';     // original uploaded filename (for batch naming)\u001b[39m\n\u001b[31mlet _previewUpload = null;       // upload object currently shown in s-upload-preview\u001b[39m\n\u001b[31mlet _navStack     = [];          // history stack for back/forward navigation·\u001b[39m\n\u001b[31m// Many real-world Excel files have 1–5 title/logo rows above the actual column\u001b[39m\n\u001b[31m// headers (merged cells, report titles, date stamps, etc.). SheetJS naively uses\u001b[39m\n\u001b[31m// row 0 as headers, so those title rows break column detection completely.\u001b[39m\n\u001b[31m// This function scans the first 20 rows and returns the index of the first row\u001b[39m\n\u001b[31m// where ≥ 2 cells match recognisable column-header keywords.\u001b[39m\n\u001b[31mfunction findHeaderRowIndex(rawRows) {\u001b[39m\n\u001b[31m  const HDRKW = /\\\\b(name|store|shop|outlet|party|dealer|firm|customer|business|company|client|merchant|address|addr|location|loc|city|town|district|state|lat|latitude|lng|lon|longitude|phone|mobile|contact|sr|s\\\\.?\\\\s*no|serial|sno|notes?|remarks?)\\\\b/i;\u001b[39m\n\u001b[31m  for (let i = 0; i < Math.min(rawRows.length, 20); i++) {\u001b[39m\n\u001b[31m    const hits = rawRows[i].filter(c => HDRKW.test(String(c))).length;\u001b[39m\n\u001b[31m    if (hits >= 2) return i;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return 0; // fallback: first row is the header\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showFileError(summaryEl, previewEl, btnGo, msg) {\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"background:#2a1010;border:1px solid #7f1d1d;border-radius:10px;padding:14px 16px;color:#fca5a5;font-size:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-weight:700;font-size:13px;margin-bottom:4px\\\">❌ File error</div>\u001b[39m\n\u001b[31m      <div>${escHtml(msg)}</div>\u001b[39m\n\u001b[31m      <div style=\\\"margin-top:8px;color:var(--muted)\\\">Try selecting the file again, or choose a different file.</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  previewEl.style.display = '';\u001b[39m\n\u001b[31m  btnGo.disabled = false;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction handleFile(file) {\u001b[39m\n\u001b[31m  _currentFilename = file.name;\u001b[39m\n\u001b[31m  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';·\u001b[39m\n\u001b[31m  const previewEl = document.getElementById('parsed-preview');\u001b[39m\n\u001b[31m  const summaryEl = document.getElementById('parsed-summary');\u001b[39m\n\u001b[31m  const btnGo     = document.getElementById('btn-go');·\u001b[39m\n\u001b[31m  previewEl.style.display = '';·\u001b[39m\n\u001b[31m  // XLSX library guard — CDN might have failed to load\u001b[39m\n\u001b[31m  if (typeof XLSX === 'undefined') {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Excel library failed to load. Check your internet connection and do a hard-refresh (Ctrl+Shift+R).');\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  summaryEl.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0\\\">\u001b[39m\n\u001b[31m      <span class=\\\"spinner\\\" style=\\\"width:30px;height:30px;border-width:3px\\\"></span>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:13px;font-weight:700\\\">📂 Reading file…</div>\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;color:var(--muted)\\\">${escHtml(file.name)}</div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  btnGo.disabled = true;\u001b[39m\n\u001b[31m  btnGo.innerHTML = '⏳ Analyzing file…';·\u001b[39m\n\u001b[31m  const reader = new FileReader();\u001b[39m\n\u001b[31m  reader.onerror = () => {\u001b[39m\n\u001b[31m    showFileError(summaryEl, previewEl, btnGo, 'Could not read the file. It may be locked by another application or corrupted.');\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  reader.onload = async (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      // ── Case 2: multi-sheet → sheet selector ──\u001b[39m\n\u001b[31m      if (wb.SheetNames.length > 1) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initSheetSelect(wb, file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Parse single sheet ──\u001b[39m\n\u001b[31m      const ws = wb.Sheets[wb.SheetNames[0]];\u001b[39m\n\u001b[31m      const rawRowArrays = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });\u001b[39m\n\u001b[31m      const hdrIdx = findHeaderRowIndex(rawRowArrays);\u001b[39m\n\u001b[31m      const rawHeaders = rawRowArrays[hdrIdx].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m      const rows = XLSX.utils.sheet_to_json(ws, { defval: '', range: hdrIdx });\u001b[39m\n\u001b[31m      if (!rows.length) throw new Error('Sheet appears empty — no data rows found after the header row.');\u001b[39m\n\u001b[31m      _parsedRows = rows;\u001b[39m\n\u001b[31m      _rawHeaders = rawHeaders;·\u001b[39m\n\u001b[31m      _parsedCols = detectColumns(_rawHeaders || _parsedRows[0]);\u001b[39m\n\u001b[31m      if (_rawHeaders && _parsedRows[0]) {\u001b[39m\n\u001b[31m        const rowKeys = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m        for (const role of Object.keys(_parsedCols)) {\u001b[39m\n\u001b[31m          if (role === 'others' || !_parsedCols[role]) continue;\u001b[39m\n\u001b[31m          const detected = _parsedCols[role];\u001b[39m\n\u001b[31m          const match = rowKeys.find(k => normaliseColName(k) === normaliseColName(detected));\u001b[39m\n\u001b[31m          if (match && match !== detected) _parsedCols[role] = match;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Case 1: groupable column with 2+ distinct values → grouping screen ──\u001b[39m\n\u001b[31m      const colKeys = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m      let hasMultiGroups = false;\u001b[39m\n\u001b[31m      for (const k of colKeys) {\u001b[39m\n\u001b[31m        if (GROUPABLE_COLS.some(g => normaliseColName(k).includes(g))) {\u001b[39m\n\u001b[31m          const uniq = new Set(rows.map(r => (r[k]||'').toString().trim()).filter(Boolean));\u001b[39m\n\u001b[31m          if (uniq.size >= 2) { hasMultiGroups = true; break; }\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      }\u001b[39m\n\u001b[31m      if (hasMultiGroups) {\u001b[39m\n\u001b[31m        btnGo.disabled = false;\u001b[39m\n\u001b[31m        btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m        initGrouping(file.name);\u001b[39m\n\u001b[31m        return;\u001b[39m\n\u001b[31m      }·\u001b[39m\n\u001b[31m      // ── Single group: existing direct flow ──\u001b[39m\n\u001b[31m      rebuildStopsFromParsedRows();\u001b[39m\n\u001b[31m      renderParsedPreview();\u001b[39m\n\u001b[31m      updateResumeBtn();\u001b[39m\n\u001b[31m      btnGo.disabled = false;\u001b[39m\n\u001b[31m      btnGo.innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m      toast(`${state.stops.length} stops ready`, 'ok');\u001b[39m\n\u001b[31m    } catch (err) {\u001b[39m\n\u001b[31m      showFileError(summaryEl, previewEl, btnGo, err.message);\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  if (_loadedFromUpload) {\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 class=\\\"section-title\\\" style=\\\"margin-bottom:6px\\\">Loaded</div>` +\u001b[39m\n\u001b[31m      `<div>📂 <b>${state.stops.length}</b> geocoded stops from <b>${escHtml(_loadedUploadFilename)}</b></div>\u001b[39m\n\u001b[31m       <div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">All stops pre-geocoded. Adjust origin below if needed, then click <b>Parse + optimise route</b>.</div>`;\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const skip = _parsedRows.length - state.stops.length;\u001b[39m\n\u001b[31m  // Use _parsedRows[0] keys (actual SheetJS keys, so dropdown values match row lookups)\u001b[39m\n\u001b[31m  // but filter out __EMPTY_N synthetic keys for blank header cells.\u001b[39m\n\u001b[31m  const allCols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\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','state','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;font-size:12px\\\"><b>${state.stops.length}</b> stops` +\u001b[39m\n\u001b[31m    `${skip > 0 ? ` · <span style=\\\"color:var(--muted)\\\">${skip} skipped</span>` : ''}</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:10.5px;color:var(--muted);margin-bottom:6px\\\">Column mapping (verify below):</div>` +\u001b[39m\n\u001b[31m    rows;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════\u001b[39m\n\u001b[31m//  BATCH / MULTI-TOUR\u001b[39m\n\u001b[31m// ═══════════════════════════════════════════════════════════════════════════·\u001b[39m\n\u001b[31mconst GROUPABLE_COLS = ['state','city','region','district','category','area','zone','territory','cluster','beat'];·\u001b[39m\n\u001b[31m// ── Case 2: multi-sheet workbook ──────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initSheetSelect(wb, filename) {\u001b[39m\n\u001b[31m  _workbook = wb;\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const GENERIC = /^(sheet\\\\d*|all|master|summary|data|combined|total|overview|index)$/i;\u001b[39m\n\u001b[31m  const sizes = wb.SheetNames.map(n => {\u001b[39m\n\u001b[31m    const nz = XLSX.utils.sheet_to_json(wb.Sheets[n], { header:1, defval:'' })\u001b[39m\n\u001b[31m                   .filter(r => r.some(c => String(c).trim())).length;\u001b[39m\n\u001b[31m    return { name: n, count: nz };\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  const maxCount = Math.max(...sizes.map(s => s.count));\u001b[39m\n\u001b[31m  const uniqueMax = sizes.filter(s => s.count === maxCount).length === 1;\u001b[39m\n\u001b[31m  document.getElementById('sheet-check-list').innerHTML = sizes.map(s => {\u001b[39m\n\u001b[31m    const isMaster = (uniqueMax && s.count === maxCount) || GENERIC.test(s.name.trim());\u001b[39m\n\u001b[31m    return `<label style=\\\"display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer\\\">\u001b[39m\n\u001b[31m      <input type=\\\"checkbox\\\" ${!isMaster ? 'checked' : ''} value=\\\"${escHtml(s.name)}\\\" style=\\\"width:16px;height:16px;accent-color:var(--accent)\\\">\u001b[39m\n\u001b[31m      <div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:13px;font-weight:700\\\">${escHtml(s.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted)\\\">${Math.max(0,s.count-1)} stores${isMaster ? ' &nbsp;<span style=\\\"color:var(--y)\\\">· master sheet</span>' : ''}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </label>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-sheet').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  showScreen('s-sheet-select');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmSheetSelect() {\u001b[39m\n\u001b[31m  const checked = [...document.querySelectorAll('#sheet-check-list input:checked')];\u001b[39m\n\u001b[31m  if (!checked.length) return toast('Select at least one sheet', 'err');\u001b[39m\n\u001b[31m  _batchName   = document.getElementById('inp-batch-name-sheet').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _batchGroups = []; _batchId = null; _groupCol = null;\u001b[39m\n\u001b[31m  for (const cb of checked) {\u001b[39m\n\u001b[31m    const ws = _workbook.Sheets[cb.value];\u001b[39m\n\u001b[31m    const arr = XLSX.utils.sheet_to_json(ws, { header:1, defval:'' });\u001b[39m\n\u001b[31m    const hi  = findHeaderRowIndex(arr);\u001b[39m\n\u001b[31m    const rh  = arr[hi].map(h => String(h)).filter(h => h.trim() && h !== 'undefined');\u001b[39m\n\u001b[31m    const rows = XLSX.utils.sheet_to_json(ws, { defval:'', range:hi });\u001b[39m\n\u001b[31m    if (!rows.length) continue;\u001b[39m\n\u001b[31m    let pc = detectColumns(rh);\u001b[39m\n\u001b[31m    const rk = Object.keys(rows[0]).filter(k => !k.startsWith('__EMPTY'));\u001b[39m\n\u001b[31m    for (const role of Object.keys(pc)) {\u001b[39m\n\u001b[31m      if (role === 'others' || !pc[role]) continue;\u001b[39m\n\u001b[31m      const m = rk.find(k => normaliseColName(k) === normaliseColName(pc[role]));\u001b[39m\n\u001b[31m      if (m && m !== pc[role]) pc[role] = m;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name:cb.value,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:rh, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No data found in selected sheets', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Case 1: single-sheet grouping ─────────────────────────────────────────\u001b[39m\n\u001b[31mfunction initGrouping(filename) {\u001b[39m\n\u001b[31m  _uploadFilename = filename;\u001b[39m\n\u001b[31m  const cols = Object.keys(_parsedRows[0]).filter(k => !k.startsWith('__EMPTY') && k.trim());\u001b[39m\n\u001b[31m  let bestCol = null, bestScore = -1;\u001b[39m\n\u001b[31m  for (const h of (_rawHeaders || cols)) {\u001b[39m\n\u001b[31m    const n = normaliseColName(h);\u001b[39m\n\u001b[31m    const rank = GROUPABLE_COLS.findIndex(g => n.includes(g));\u001b[39m\n\u001b[31m    if (rank !== -1 && (GROUPABLE_COLS.length - rank) > bestScore) {\u001b[39m\n\u001b[31m      bestScore = GROUPABLE_COLS.length - rank;\u001b[39m\n\u001b[31m      bestCol   = cols.find(k => normaliseColName(k) === n) || h;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const sel = document.getElementById('sel-group-col');\u001b[39m\n\u001b[31m  sel.innerHTML = cols.map(c => `<option value=\\\"${escHtml(c)}\\\" ${c===bestCol?'selected':''}>${escHtml(c)}</option>`).join('');\u001b[39m\n\u001b[31m  document.getElementById('inp-batch-name-group').value = filename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  updateGroupingPreview();\u001b[39m\n\u001b[31m  showScreen('s-grouping');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateGroupingPreview() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  if (!col || !_parsedRows) return;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=0; order.push(v); }\u001b[39m\n\u001b[31m    groups[v]++;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  document.getElementById('grouping-preview').innerHTML =\u001b[39m\n\u001b[31m    `<div style=\\\"font-size:11px;color:var(--muted);margin-bottom:6px\\\">${order.length} tour${order.length!==1?'s':''} proposed:</div>` +\u001b[39m\n\u001b[31m    `<div style=\\\"display:flex;flex-direction:column;gap:6px\\\">` +\u001b[39m\n\u001b[31m    order.map(name => {\u001b[39m\n\u001b[31m      const n=groups[name], over=n>cap;\u001b[39m\n\u001b[31m      return `<div style=\\\"display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid ${over?'rgba(234,179,8,.4)':'var(--border)'};border-radius:8px;padding:9px 12px;font-size:12px\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700\\\">${escHtml(name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"${over?'color:var(--y)':''}\\\">${n} stores${over?' ⚠':''}</span></div>`;\u001b[39m\n\u001b[31m    }).join('') + '</div>';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGrouping() {\u001b[39m\n\u001b[31m  const col = document.getElementById('sel-group-col').value;\u001b[39m\n\u001b[31m  const cap = parseInt(document.getElementById('inp-cap').value) || 20;\u001b[39m\n\u001b[31m  _batchName = document.getElementById('inp-batch-name-group').value.trim() || _uploadFilename.replace(/\\\\.[^.]+$/, '');\u001b[39m\n\u001b[31m  _groupCol = col; _batchGroups = []; _batchId = null;\u001b[39m\n\u001b[31m  const groups = {}, order = [];\u001b[39m\n\u001b[31m  for (const row of _parsedRows) {\u001b[39m\n\u001b[31m    const v = (row[col]||'').toString().trim() || '(blank)';\u001b[39m\n\u001b[31m    if (!groups[v]) { groups[v]=[]; order.push(v); }\u001b[39m\n\u001b[31m    groups[v].push(row);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  for (const name of order) {\u001b[39m\n\u001b[31m    const rows = groups[name];\u001b[39m\n\u001b[31m    const pc = { ..._parsedCols, others:[] };\u001b[39m\n\u001b[31m    _batchGroups.push({ localId:'g'+_batchGroups.length, dbId:null, name,\u001b[39m\n\u001b[31m      rawRows:rows, rawHeaders:_rawHeaders, parsedCols:pc,\u001b[39m\n\u001b[31m      stops:rows.map((r,i)=>buildStop(r,pc,i)).filter(Boolean),\u001b[39m\n\u001b[31m      planned:false, capWarn:rows.length>cap, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!_batchGroups.length) return toast('No groups found', 'err');\u001b[39m\n\u001b[31m  renderGroupEditor(); showScreen('s-group-editor');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch review screen ───────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderBatchReview() {\u001b[39m\n\u001b[31m  document.getElementById('batch-review-title').textContent = '📦 ' + (_batchName || 'Tour Batch');\u001b[39m\n\u001b[31m  const planned = _batchGroups.filter(g=>g.planned).length;\u001b[39m\n\u001b[31m  document.getElementById('batch-review-sub').textContent = `${_batchGroups.length} tours · ${planned} planned`;\u001b[39m\n\u001b[31m  const canvasBtn = document.getElementById('btn-canvas');\u001b[39m\n\u001b[31m  if (canvasBtn) canvasBtn.style.display = _batchGroups.length >= 2 ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('batch-group-cards').innerHTML = _batchGroups.map((g, idx) => {\u001b[39m\n\u001b[31m    const cities = [...new Set(g.stops.slice(0,6).map(s=>s.city).filter(Boolean))].slice(0,4).join(', ');\u001b[39m\n\u001b[31m    const badge = g.planned\u001b[39m\n\u001b[31m      ? `<span style=\\\"background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:12px;padding:2px 8px;font-size:10px;font-weight:700\\\">✓ Planned</span>`\u001b[39m\n\u001b[31m      : `<span style=\\\"background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border);border-radius:12px;padding:2px 8px;font-size:10px\\\">Pending</span>`;\u001b[39m\n\u001b[31m    const warn = g.capWarn ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ ${g.stops.length} stops — over cap. Exclude stops using ✗ in the editor.</div>` : '';\u001b[39m\n\u001b[31m    const splitBtn = g.capWarn && !g.planned\u001b[39m\n\u001b[31m      ? `<button onclick=\\\"showSplitUI(${idx})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:8px;padding:4px 10px;cursor:pointer;margin-top:6px\\\">✂ Split into sub-tours</button>` : '';\u001b[39m\n\u001b[31m    const splitUI = `<div id=\\\"split-ui-${idx}\\\" style=\\\"display:none;margin-top:8px;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"font-size:11px;margin-bottom:6px\\\">Split at stop: <input type=\\\"number\\\" id=\\\"split-at-${idx}\\\" min=\\\"1\\\" max=\\\"${g.stops.length-1}\\\" value=\\\"${Math.round(g.stops.length/2)}\\\" style=\\\"width:55px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;color:var(--fg);font-size:12px\\\"> of ${g.stops.length}</div>\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;gap:6px\\\">\u001b[39m\n\u001b[31m        <button onclick=\\\"execSplit(${idx})\\\" style=\\\"font-size:11px;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:5px 12px;cursor:pointer\\\">Confirm split</button>\u001b[39m\n\u001b[31m        <button onclick=\\\"document.getElementById('split-ui-${idx}').style.display='none'\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:5px 12px;cursor:pointer\\\">Cancel</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px\\\">\u001b[39m\n\u001b[31m      <div style=\\\"display:flex;align-items:flex-start;justify-content:space-between;gap:8px\\\">\u001b[39m\n\u001b[31m        <div style=\\\"flex:1\\\">\u001b[39m\n\u001b[31m          <div style=\\\"display:flex;align-items:center;gap:8px;margin-bottom:4px\\\">\u001b[39m\n\u001b[31m            <span style=\\\"font-size:14px;font-weight:700\\\">${escHtml(g.name)}</span>${badge}\u001b[39m\n\u001b[31m          </div>\u001b[39m\n\u001b[31m          <div style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores${cities?' · '+escHtml(cities):''}</div>\u001b[39m\n\u001b[31m          ${warn}${splitBtn}${splitUI}\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <button onclick=\\\"openBatchGroup(${idx})\\\" style=\\\"background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0\\\">${g.planned?'✏ Revise':'▶ Plan'}</button>\u001b[39m\n\u001b[31m      </div></div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showSplitUI(idx) { document.getElementById('split-ui-'+idx).style.display=''; }·\u001b[39m\n\u001b[31mfunction execSplit(idx) {\u001b[39m\n\u001b[31m  const at = parseInt(document.getElementById('split-at-'+idx).value);\u001b[39m\n\u001b[31m  const g  = _batchGroups[idx];\u001b[39m\n\u001b[31m  if (!at || at<1 || at>=g.stops.length) return toast('Invalid split point','err');\u001b[39m\n\u001b[31m  const mk = (suffix, rows, stops) => ({\u001b[39m\n\u001b[31m    localId:g.localId+suffix, dbId:null, name:g.name+' — Part '+(suffix==='a'?1:2),\u001b[39m\n\u001b[31m    rawRows:rows, rawHeaders:g.rawHeaders, parsedCols:{...g.parsedCols,others:[]},\u001b[39m\n\u001b[31m    stops, planned:false, capWarn:false });\u001b[39m\n\u001b[31m  _batchGroups.splice(idx,1, mk('a',g.rawRows.slice(0,at),g.stops.slice(0,at)), mk('b',g.rawRows.slice(at),g.stops.slice(at)));\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  toast(`Split: ${at} + ${g.stops.length-at} stores`,'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Open a group in the editor ────────────────────────────────────────────\u001b[39m\n\u001b[31masync function openBatchGroup(idx) {\u001b[39m\n\u001b[31m  const g = _batchGroups[idx];\u001b[39m\n\u001b[31m  _batchGroupIdx = idx;\u001b[39m\n\u001b[31m  if (!_batchId) await saveBatchToDB();\u001b[39m\n\u001b[31m  _parsedRows          = g.rawRows;\u001b[39m\n\u001b[31m  _rawHeaders          = g.rawHeaders;\u001b[39m\n\u001b[31m  _parsedCols          = { ...g.parsedCols };\u001b[39m\n\u001b[31m  _currentFilename     = _batchName + ' — ' + g.name + '.xlsx';\u001b[39m\n\u001b[31m  _loadedFromUpload    = false;\u001b[39m\n\u001b[31m  _loadedUploadFilename = '';\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = g.stops.map(s => ({...s}));\u001b[39m\n\u001b[31m  const titleEl = document.getElementById('editor-title');\u001b[39m\n\u001b[31m  if (titleEl) titleEl.textContent = g.name;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = '';\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m  document.getElementById('btn-go').innerHTML = '📐 Parse + optimise route';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function batchBack() {\u001b[39m\n\u001b[31m  if (_batchGroupIdx !== null) _batchGroups[_batchGroupIdx].planned = state.proposedOrder !== null;\u001b[39m\n\u001b[31m  _canvasData = []; // force reload next canvas open\u001b[39m\n\u001b[31m  _batchGroupIdx = null;\u001b[39m\n\u001b[31m  document.getElementById('btn-batch-back').style.display = 'none';\u001b[39m\n\u001b[31m  const t = document.getElementById('editor-title'); if (t) t.textContent = 'Tour Editor';\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review', false);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── DB save / plan marking ────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function saveBatchToDB() {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/batches', {\u001b[39m\n\u001b[31m      name:_batchName, filename:_uploadFilename||_currentFilename,\u001b[39m\n\u001b[31m      source_type:_groupCol?'single_sheet':'multi_sheet', group_col:_groupCol||null,\u001b[39m\n\u001b[31m      groups:_batchGroups.map(g=>({ localId:g.localId, name:g.name, stops:g.stops })),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    _batchId = r.id;\u001b[39m\n\u001b[31m    for (const m of (r.groups||[])) { const g=_batchGroups.find(x=>x.localId===m.localId); if(g) g.dbId=m.dbId; }\u001b[39m\n\u001b[31m  } catch (e) { console.warn('[batch save]',e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function markGroupPlanned(uploadId, savedTourId) {\u001b[39m\n\u001b[31m  if (_batchGroupIdx === null || !_batchId) return;\u001b[39m\n\u001b[31m  const g = _batchGroups[_batchGroupIdx];\u001b[39m\n\u001b[31m  if (!g?.dbId) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const resp = await fetch(PROXY + '/db/batches/groups/' + g.dbId + '/plan', {\u001b[39m\n\u001b[31m      method: 'PATCH',\u001b[39m\n\u001b[31m      headers: { 'Authorization': hubAuth(), 'Content-Type': 'application/json' },\u001b[39m\n\u001b[31m      body: JSON.stringify({ upload_id: uploadId || null, saved_tour_id: savedTourId || null }),\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (resp.ok) {\u001b[39m\n\u001b[31m      g.planned = true;\u001b[39m\n\u001b[31m      if (uploadId)    g.uploadId    = uploadId;\u001b[39m\n\u001b[31m      if (savedTourId) g.savedTourId = savedTourId;\u001b[39m\n\u001b[31m      _canvasData = []; // invalidate canvas cache\u001b[39m\n\u001b[31m      renderBatchReview();\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch(e) { console.warn('[batch mark]', e.message); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch history tab ─────────────────────────────────────────────────────\u001b[39m\n\u001b[31masync function histLoadBatches() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-batches-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/batches');\u001b[39m\n\u001b[31m    if (!rows.length) { el.innerHTML='<div class=\\\"empty\\\">No saved batches yet.<br>Upload a multi-region Excel to create a batch.</div>'; return; }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(b => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📦 ${escHtml(b.name)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${b.group_count} tours · ${b.planned_count} planned · ${fmtDate(b.created_at)}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\" style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(b.filename||'')}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"resumeBatch('${escHtml(b.id)}')\\\">▶ Open</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteBatch('${escHtml(b.id)}',this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch(e) { el.innerHTML=`<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Load failed: ${escHtml(e.message)}</div>`; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function resumeBatch(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const b = await apiGet('/db/batches/'+id);\u001b[39m\n\u001b[31m    _batchId=b.id; _batchName=b.name; _uploadFilename=b.filename||''; _groupCol=b.group_col||null;\u001b[39m\n\u001b[31m    _canvasData = [];\u001b[39m\n\u001b[31m    _batchGroups=(b.groups||[]).map(g=>({\u001b[39m\n\u001b[31m      localId:g.id, dbId:g.id, name:g.name,\u001b[39m\n\u001b[31m      rawRows:g.stops||[], rawHeaders:[], parsedCols:{},\u001b[39m\n\u001b[31m      stops:g.stops||[], planned:!!g.planned_at, capWarn:false,\u001b[39m\n\u001b[31m      uploadId:g.upload_id||null, savedTourId:g.saved_tour_id||null }));\u001b[39m\n\u001b[31m    renderBatchReview(); showScreen('s-batch-review');\u001b[39m\n\u001b[31m  } catch(e) { toast('Failed to load batch: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteBatch(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this batch and all its tour groups?')) return;\u001b[39m\n\u001b[31m  try { await apiDel('/db/batches/'+id); btn.closest('.hist-item').remove(); toast('Batch deleted','ok'); }\u001b[39m\n\u001b[31m  catch(e) { toast('Delete failed: '+e.message,'err'); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Group editor ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mfunction renderGroupEditor() {\u001b[39m\n\u001b[31m  const sub = document.getElementById('ge-sub');\u001b[39m\n\u001b[31m  const total = _batchGroups.reduce((a,g)=>a+g.stops.length,0);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_batchGroups.length} groups · ${total} stores`;\u001b[39m\n\u001b[31m  const body = document.getElementById('ge-body');\u001b[39m\n\u001b[31m  if (!body) return;\u001b[39m\n\u001b[31m  body.innerHTML = _batchGroups.map((g, gi) => {\u001b[39m\n\u001b[31m    const rows = g.stops.length === 0\u001b[39m\n\u001b[31m      ? `<div style=\\\"padding:12px;font-size:12px;color:var(--muted);text-align:center\\\">(empty — move stores here via dropdown)</div>`\u001b[39m\n\u001b[31m      : `<table class=\\\"ge-table\\\">\u001b[39m\n\u001b[31m          <thead><tr><th>Store</th><th>City</th><th>Move to</th></tr></thead>\u001b[39m\n\u001b[31m          <tbody>${g.stops.map((s,si)=>`<tr>\u001b[39m\n\u001b[31m            <td>${escHtml(s.name||'—')}</td>\u001b[39m\n\u001b[31m            <td style=\\\"color:var(--muted)\\\">${escHtml(s.city||'—')}</td>\u001b[39m\n\u001b[31m            <td><select class=\\\"ge-select\\\" onchange=\\\"moveStoreA(${gi},${si},this.value)\\\">\u001b[39m\n\u001b[31m              ${_batchGroups.map((tg,tgi)=>`<option value=\\\"${tgi}\\\"${tgi===gi?' selected':''}>${escHtml(tg.name)}</option>`).join('')}\u001b[39m\n\u001b[31m              <option value=\\\"__new__\\\">+ New Group</option>\u001b[39m\n\u001b[31m            </select></td>\u001b[39m\n\u001b[31m          </tr>`).join('')}</tbody>\u001b[39m\n\u001b[31m        </table>`;\u001b[39m\n\u001b[31m    return `<div style=\\\"background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:12px;overflow:hidden\\\">\u001b[39m\n\u001b[31m      <div style=\\\"padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between\\\">\u001b[39m\n\u001b[31m        <span style=\\\"font-weight:700;font-size:13px\\\">${escHtml(g.name)}</span>\u001b[39m\n\u001b[31m        <span style=\\\"font-size:11px;color:var(--muted)\\\">${g.stops.length} stores</span>\u001b[39m\n\u001b[31m      </div>${rows}</div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreA(fromGi, stopIdx, toValue) {\u001b[39m\n\u001b[31m  if (toValue === '__new__') {\u001b[39m\n\u001b[31m    _batchGroups.push({\u001b[39m\n\u001b[31m      localId:'g'+Date.now(), dbId:null, name:'Group '+(_batchGroups.length+1),\u001b[39m\n\u001b[31m      rawRows:[], rawHeaders:_batchGroups[0]?.rawHeaders||[], parsedCols:{...(_batchGroups[0]?.parsedCols||{}),others:[]},\u001b[39m\n\u001b[31m      stops:[], planned:false, capWarn:false, uploadId:null, savedTourId:null });\u001b[39m\n\u001b[31m    toValue = String(_batchGroups.length - 1);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const toGi = parseInt(toValue);\u001b[39m\n\u001b[31m  if (isNaN(toGi) || toGi === fromGi) return;\u001b[39m\n\u001b[31m  const [stop] = _batchGroups[fromGi].stops.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  const [raw]  = _batchGroups[fromGi].rawRows.splice(stopIdx, 1);\u001b[39m\n\u001b[31m  _batchGroups[toGi].stops.push(stop);\u001b[39m\n\u001b[31m  if (raw !== undefined) _batchGroups[toGi].rawRows.push(raw);\u001b[39m\n\u001b[31m  _batchId = null;\u001b[39m\n\u001b[31m  renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction confirmGroupEditor() {\u001b[39m\n\u001b[31m  renderBatchReview();\u001b[39m\n\u001b[31m  showScreen('s-batch-review');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Batch canvas ──────────────────────────────────────────────────────────\u001b[39m\n\u001b[31mlet _canvasData = [];   // [{gi,name,stops,proposedOrder,totalKm,stale}]\u001b[39m\n\u001b[31mlet _dragSrc    = null; // {li,si} — source lane index + position in order\u001b[39m\n\u001b[31mlet _canvasMaps = {};   // li → {map,poly}·\u001b[39m\n\u001b[31masync function openBatchCanvas() {\u001b[39m\n\u001b[31m  showScreen('s-batch-canvas');\u001b[39m\n\u001b[31m  if (!_canvasData.length) {\u001b[39m\n\u001b[31m    document.getElementById('canvas-sub').textContent = 'Loading…';\u001b[39m\n\u001b[31m    await loadCanvasData();\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  renderCanvas();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadCanvasData() {\u001b[39m\n\u001b[31m  _canvasData = [];\u001b[39m\n\u001b[31m  for (let i = 0; i < _batchGroups.length; i++) {\u001b[39m\n\u001b[31m    const g = _batchGroups[i];\u001b[39m\n\u001b[31m    let stops = g.stops.map(s=>({...s}));\u001b[39m\n\u001b[31m    let proposedOrder = null, totalKm = null;\u001b[39m\n\u001b[31m    if (g.uploadId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/uploads/'+g.uploadId);\u001b[39m\n\u001b[31m        if (Array.isArray(d.stops)) stops = d.stops;\u001b[39m\n\u001b[31m        if (d.result_json?.proposedOrder) {\u001b[39m\n\u001b[31m          proposedOrder = d.result_json.proposedOrder;\u001b[39m\n\u001b[31m          totalKm = d.result_json.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.result_json.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas upload]',e.message); }\u001b[39m\n\u001b[31m    } else if (g.savedTourId) {\u001b[39m\n\u001b[31m      try {\u001b[39m\n\u001b[31m        const d = await apiGet('/db/tours/'+g.savedTourId);\u001b[39m\n\u001b[31m        if (d.tour_state) {\u001b[39m\n\u001b[31m          if (Array.isArray(d.tour_state.stops)) stops = d.tour_state.stops;\u001b[39m\n\u001b[31m          proposedOrder = d.tour_state.proposedOrder || null;\u001b[39m\n\u001b[31m          totalKm = d.tour_state.currentTotal?.distance_m\u001b[39m\n\u001b[31m            ? d.tour_state.currentTotal.distance_m/1000 : null;\u001b[39m\n\u001b[31m        }\u001b[39m\n\u001b[31m      } catch(e) { console.warn('[canvas tour]',e.message); }\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    _canvasData.push({ gi:i, name:g.name, stops, proposedOrder, totalKm, stale:false });\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderCanvas() {\u001b[39m\n\u001b[31m  const lanes = document.getElementById('canvas-lanes');\u001b[39m\n\u001b[31m  const sub   = document.getElementById('canvas-sub');\u001b[39m\n\u001b[31m  const _totalKm = _canvasData.reduce((s,cd)=>s+(cd.totalKm||0),0);\u001b[39m\n\u001b[31m  const _hasKm   = _canvasData.some(cd=>cd.totalKm);\u001b[39m\n\u001b[31m  if (sub) sub.textContent = `${_canvasData.length} tours${_hasKm?' · '+_totalKm.toFixed(0)+' km total':''}`;\u001b[39m\n\u001b[31m  Object.values(_canvasMaps).forEach(m=>{ try { m.map.remove(); } catch(e){} });\u001b[39m\n\u001b[31m  _canvasMaps = {};\u001b[39m\n\u001b[31m  lanes.innerHTML = _canvasData.map((cd, li) => {\u001b[39m\n\u001b[31m    const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m    const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m    const staleTag = cd.stale\u001b[39m\n\u001b[31m      ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m      : (planned ? '' : '<div style=\\\"font-size:11px;color:var(--muted);margin-top:4px\\\">▶ Plan first from batch review</div>');\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-lane\\\" id=\\\"cl-${li}\\\">\u001b[39m\n\u001b[31m      <div class=\\\"canvas-lane-hdr\\\">\u001b[39m\n\u001b[31m        <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m        <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m        ${staleTag}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <div id=\\\"cl-map-${li}\\\" class=\\\"canvas-lane-map\\\" style=\\\"${planned?'':'display:none'}\\\"></div>\u001b[39m\n\u001b[31m      <div class=\\\"canvas-stop-list\\\" id=\\\"cl-stops-${li}\\\"\u001b[39m\n\u001b[31m           ondragover=\\\"event.preventDefault()\\\" ondrop=\\\"onLaneDrop(event,${li})\\\">\u001b[39m\n\u001b[31m        ${laneStopsHTML(cd, li)}\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  requestAnimationFrame(()=>{\u001b[39m\n\u001b[31m    _canvasData.forEach((cd,li)=>{ if(cd.proposedOrder) initCanvasMap(li); });\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction laneStopsHTML(cd, li) {\u001b[39m\n\u001b[31m  const order = cd.proposedOrder || cd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  if (!order.length) {\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-drop-zone\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">Drop stores here</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return order.map((sid,si)=>{\u001b[39m\n\u001b[31m    const s = cd.stops.find(st=>st.id===sid)||{name:sid,city:''};\u001b[39m\n\u001b[31m    return `<div class=\\\"canvas-stop-card\\\" draggable=\\\"true\\\"\u001b[39m\n\u001b[31m      ondragstart=\\\"onStopDragStart(event,${li},${si})\\\"\u001b[39m\n\u001b[31m      ondragover=\\\"event.preventDefault();this.classList.add('drag-over')\\\"\u001b[39m\n\u001b[31m      ondragleave=\\\"this.classList.remove('drag-over')\\\"\u001b[39m\n\u001b[31m      ondrop=\\\"onLaneDrop(event,${li});this.classList.remove('drag-over')\\\">\u001b[39m\n\u001b[31m      <span style=\\\"color:var(--muted);font-size:10px;flex-shrink:0\\\">${si+1}.</span>\u001b[39m\n\u001b[31m      <span style=\\\"font-weight:600\\\">${escHtml(s.name||'—')}</span>\u001b[39m\n\u001b[31m      ${s.city?`<span style=\\\"color:var(--muted);font-size:10px\\\">${escHtml(s.city)}</span>`:''}\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onStopDragStart(e, li, si) {\u001b[39m\n\u001b[31m  _dragSrc = { li, si };\u001b[39m\n\u001b[31m  e.dataTransfer.effectAllowed = 'move';\u001b[39m\n\u001b[31m  setTimeout(()=>{ if(e.target) e.target.style.opacity='.35'; }, 0);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction onLaneDrop(e, toLi) {\u001b[39m\n\u001b[31m  e.preventDefault();\u001b[39m\n\u001b[31m  document.querySelectorAll('.canvas-stop-card,.canvas-drop-zone').forEach(el=>{\u001b[39m\n\u001b[31m    el.classList.remove('drag-over');\u001b[39m\n\u001b[31m    el.style.opacity='';\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  if (!_dragSrc) return;\u001b[39m\n\u001b[31m  const { li: fromLi, si: fromSi } = _dragSrc;\u001b[39m\n\u001b[31m  _dragSrc = null;\u001b[39m\n\u001b[31m  if (fromLi === toLi) return;\u001b[39m\n\u001b[31m  moveStoreCanvas(fromLi, fromSi, toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction moveStoreCanvas(fromLi, fromSi, toLi) {\u001b[39m\n\u001b[31m  const fromCd  = _canvasData[fromLi];\u001b[39m\n\u001b[31m  const toCd    = _canvasData[toLi];\u001b[39m\n\u001b[31m  const fromOrd = fromCd.proposedOrder ? [...fromCd.proposedOrder] : fromCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const sid     = fromOrd[fromSi];\u001b[39m\n\u001b[31m  if (!sid) return;\u001b[39m\n\u001b[31m  const stop = fromCd.stops.find(s=>s.id===sid);\u001b[39m\n\u001b[31m  if (!stop) return;\u001b[39m\n\u001b[31m  fromOrd.splice(fromSi, 1);\u001b[39m\n\u001b[31m  fromCd.proposedOrder = fromOrd;\u001b[39m\n\u001b[31m  fromCd.stops = fromCd.stops.filter(s=>s.id!==sid);\u001b[39m\n\u001b[31m  fromCd.stale = fromOrd.length > 1;\u001b[39m\n\u001b[31m  const toOrd  = toCd.proposedOrder ? [...toCd.proposedOrder] : toCd.stops.map(s=>s.id);\u001b[39m\n\u001b[31m  const toStps = [...toCd.stops, stop];\u001b[39m\n\u001b[31m  const pos    = findInsertPos(toStps, toOrd, stop);\u001b[39m\n\u001b[31m  toOrd.splice(pos, 0, sid);\u001b[39m\n\u001b[31m  toCd.proposedOrder = toOrd;\u001b[39m\n\u001b[31m  toCd.stops = toStps;\u001b[39m\n\u001b[31m  toCd.stale = true;\u001b[39m\n\u001b[31m  _batchGroups[fromCd.gi].stops = fromCd.stops;\u001b[39m\n\u001b[31m  _batchGroups[toCd.gi].stops   = toStps;\u001b[39m\n\u001b[31m  reRenderLane(fromLi);\u001b[39m\n\u001b[31m  reRenderLane(toLi);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction reRenderLane(li) {\u001b[39m\n\u001b[31m  const cd   = _canvasData[li];\u001b[39m\n\u001b[31m  const lane = document.getElementById('cl-'+li);\u001b[39m\n\u001b[31m  if (!lane) return;\u001b[39m\n\u001b[31m  const planned = !!cd.proposedOrder;\u001b[39m\n\u001b[31m  const kmStr   = cd.totalKm !== null && cd.totalKm !== undefined ? cd.totalKm.toFixed(1)+' km' : '—';\u001b[39m\n\u001b[31m  const staleTag = cd.stale\u001b[39m\n\u001b[31m    ? `<div style=\\\"font-size:11px;color:var(--y);margin-top:4px\\\">⚠ Stale · <button id=\\\"cl-reopt-${li}\\\" onclick=\\\"nnReorderLane(${li})\\\" style=\\\"font-size:11px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:var(--y);border-radius:6px;padding:2px 8px;cursor:pointer\\\">↺ Re-sort</button> · <button onclick=\\\"openBatchGroup(${cd.gi})\\\" style=\\\"font-size:11px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer\\\">✏ Full optimize</button></div>`\u001b[39m\n\u001b[31m    : '';\u001b[39m\n\u001b[31m  const hdr = lane.querySelector('.canvas-lane-hdr');\u001b[39m\n\u001b[31m  if (hdr) hdr.innerHTML = `\u001b[39m\n\u001b[31m    <div style=\\\"font-weight:700;font-size:13px\\\">${escHtml(cd.name)}</div>\u001b[39m\n\u001b[31m    <div style=\\\"font-size:11px;color:var(--muted);margin-top:2px\\\">${cd.stops.length} stores · ${kmStr}</div>\u001b[39m\n\u001b[31m    ${staleTag}`;\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (mapEl) mapEl.style.display = planned ? '' : 'none';\u001b[39m\n\u001b[31m  const list = document.getElementById('cl-stops-'+li);\u001b[39m\n\u001b[31m  if (list) list.innerHTML = laneStopsHTML(cd, li);\u001b[39m\n\u001b[31m  updateCanvasMap(li);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction initCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd    = _canvasData[li];\u001b[39m\n\u001b[31m  const mapEl = document.getElementById('cl-map-'+li);\u001b[39m\n\u001b[31m  if (!mapEl || !cd.proposedOrder) return;\u001b[39m\n\u001b[31m  const ordered = cd.proposedOrder.map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  if (_canvasMaps[li]) { try { _canvasMaps[li].map.remove(); } catch(e){} }\u001b[39m\n\u001b[31m  const map = L.map(mapEl, { zoomControl:false, attributionControl:false });\u001b[39m\n\u001b[31m  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(map);\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  const poly = L.polyline(lls,{color:'#6366f1',weight:2.5,opacity:.75}).addTo(map);\u001b[39m\n\u001b[31m  ordered.forEach((s,i)=>L.circleMarker([s.location.lat,s.location.lng],{radius:5,fillColor:'#6366f1',color:'#fff',weight:1.5,fillOpacity:1})\u001b[39m\n\u001b[31m    .bindTooltip((i+1)+'. '+escHtml(s.name),{permanent:false,direction:'top'}).addTo(map));\u001b[39m\n\u001b[31m  map.fitBounds(poly.getBounds(),{padding:[6,6]});\u001b[39m\n\u001b[31m  _canvasMaps[li] = { map, poly };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateCanvasMap(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  if (!_canvasMaps[li]) { initCanvasMap(li); return; }\u001b[39m\n\u001b[31m  const { map, poly } = _canvasMaps[li];\u001b[39m\n\u001b[31m  const ordered = (cd.proposedOrder||cd.stops.map(s=>s.id)).map(id=>cd.stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  if (!ordered.length) return;\u001b[39m\n\u001b[31m  const lls = ordered.map(s=>[s.location.lat,s.location.lng]);\u001b[39m\n\u001b[31m  poly.setLatLngs(lls);\u001b[39m\n\u001b[31m  try { map.fitBounds(poly.getBounds(),{padding:[6,6]}); } catch(e){}\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction haversineDist(a, b) {\u001b[39m\n\u001b[31m  const R=6371, toR=Math.PI/180;\u001b[39m\n\u001b[31m  const dLat=(b.lat-a.lat)*toR, dLng=(b.lng-a.lng)*toR;\u001b[39m\n\u001b[31m  const s1=Math.sin(dLat/2), s2=Math.sin(dLng/2);\u001b[39m\n\u001b[31m  const h=s1*s1+Math.cos(a.lat*toR)*Math.cos(b.lat*toR)*s2*s2;\u001b[39m\n\u001b[31m  return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h));\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction findInsertPos(stops, order, newStop) {\u001b[39m\n\u001b[31m  if (!order.length || !newStop?.location) return order.length;\u001b[39m\n\u001b[31m  const orded = order.map(id=>stops.find(s=>s.id===id)).filter(s=>s?.location);\u001b[39m\n\u001b[31m  let best=Infinity, pos=orded.length;\u001b[39m\n\u001b[31m  for (let i=0; i<=orded.length; i++) {\u001b[39m\n\u001b[31m    const prev=orded[i-1], next=orded[i];\u001b[39m\n\u001b[31m    let cost;\u001b[39m\n\u001b[31m    if (prev&&next) cost=haversineDist(prev.location,newStop.location)+haversineDist(newStop.location,next.location)-haversineDist(prev.location,next.location);\u001b[39m\n\u001b[31m    else if (prev) cost=haversineDist(prev.location,newStop.location);\u001b[39m\n\u001b[31m    else if (next) cost=haversineDist(newStop.location,next.location);\u001b[39m\n\u001b[31m    else cost=0;\u001b[39m\n\u001b[31m    if (cost<best) { best=cost; pos=i; }\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  return pos;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction nnReorderLane(li) {\u001b[39m\n\u001b[31m  const cd = _canvasData[li];\u001b[39m\n\u001b[31m  const geo = cd.stops.filter(s=>s.location);\u001b[39m\n\u001b[31m  if (geo.length < 2) { toast('Need ≥2 geocoded stops','err'); return; }\u001b[39m\n\u001b[31m  const unv = [...geo];\u001b[39m\n\u001b[31m  const ord = [unv.shift()];\u001b[39m\n\u001b[31m  while (unv.length) {\u001b[39m\n\u001b[31m    const last=ord[ord.length-1];\u001b[39m\n\u001b[31m    let bestD=Infinity, bestI=0;\u001b[39m\n\u001b[31m    unv.forEach((s,i)=>{ const d=haversineDist(last.location,s.location); if(d<bestD){bestD=d;bestI=i;} });\u001b[39m\n\u001b[31m    ord.push(unv.splice(bestI,1)[0]);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  cd.proposedOrder = ord.map(s=>s.id);\u001b[39m\n\u001b[31m  cd.stale = false;\u001b[39m\n\u001b[31m  cd.totalKm = ord.reduce((s,st,i)=>i===0?s:s+haversineDist(ord[i-1].location,st.location),0);\u001b[39m\n\u001b[31m  reRenderLane(li);\u001b[39m\n\u001b[31m  toast('Route re-sorted ✓ (nearest-neighbour, ~km)','ok');\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(sampleRowOrHeaders) {\u001b[39m\n\u001b[31m  const map = { name:null, city:null, state:null, address:null, lat:null, lng:null, phone:null, notes:null, others:[] };\u001b[39m\n\u001b[31m  // Accept either a raw-headers array OR a SheetJS row object (whose keys may contain __EMPTY garbage)\u001b[39m\n\u001b[31m  const keys = Array.isArray(sampleRowOrHeaders)\u001b[39m\n\u001b[31m    ? sampleRowOrHeaders\u001b[39m\n\u001b[31m    : Object.keys(sampleRowOrHeaders).filter(k => !k.startsWith('__EMPTY'));\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|storeaddress|fulladdress|streetaddress|addr|location|loc|street|shopaddress|outletaddress)$/.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.state && /^(state|province|statename|provincename)$/.test(n)) { map.state = 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  const stateV  = cols.state   ? String(row[cols.state]  || '').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    state:        stateV,            // raw state from the sheet (for geocode cross-check)\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    // ── geocode-confidence flag (set during geocoding; null = confident) ──\u001b[39m\n\u001b[31m    geoFlag:      null,   // 'ambiguous' | 'approximate' | 'unverified'\u001b[39m\n\u001b[31m    geoMsg:       '',     // human-readable reason for the badge\u001b[39m\n\u001b[31m    geoCandidates:null,   // [{label, location, formatted}] for the 2-pin Fix modal\u001b[39m\n\u001b[31m    raw:          { ...row },\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Factory for a MANUALLY-added stop (manual planner). Returns the EXACT same\u001b[39m\n\u001b[31m// shape buildStop() produces so the editor / share / CSV / saveLocal / drag /\u001b[39m\n\u001b[31m// v18-flag code all work unchanged. Manual stops are user-placed → no geocode\u001b[39m\n\u001b[31m// confidence flags engage. `stateName` is the param (NOT the global `state`).\u001b[39m\n\u001b[31mfunction makeManualStop({ name = '', location = null, city = '', address = '',\u001b[39m\n\u001b[31m                          provider = 'manual', phone = '', stateName = '' } = {}) {\u001b[39m\n\u001b[31m  const display = name || city || 'Stop';\u001b[39m\n\u001b[31m  return {\u001b[39m\n\u001b[31m    id:            uid('st'),\u001b[39m\n\u001b[31m    name:          display,\u001b[39m\n\u001b[31m    hasExplicitName: !!name,\u001b[39m\n\u001b[31m    city, phone, notes: '',\u001b[39m\n\u001b[31m    state:         stateName,\u001b[39m\n\u001b[31m    address:       address || name || '',\u001b[39m\n\u001b[31m    location:      location || null,\u001b[39m\n\u001b[31m    provider,\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    geoFlag:       null,\u001b[39m\n\u001b[31m    geoMsg:        '',\u001b[39m\n\u001b[31m    geoCandidates: null,\u001b[39m\n\u001b[31m    raw:           {},\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Geocode confidence: state normalisation + cross-check ───────────\u001b[39m\n\u001b[31m// Maps the sheet's (often non-standard) state text to a canonical name so we\u001b[39m\n\u001b[31m// can (a) append it to the geocode query and (b) compare it to the state Google\u001b[39m\n\u001b[31m// returns. Unknown states pass through unchanged — never blocks, only helps.\u001b[39m\n\u001b[31mfunction normState(s) {\u001b[39m\n\u001b[31m  if (!s) return '';\u001b[39m\n\u001b[31m  const k = String(s).trim().toLowerCase().replace(/[^a-z&]/g, '');\u001b[39m\n\u001b[31m  const M = {\u001b[39m\n\u001b[31m    'jk':'Jammu and Kashmir','j&k':'Jammu and Kashmir','jandk':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'jammukashmir':'Jammu and Kashmir','jammuandkashmir':'Jammu and Kashmir','jammu':'Jammu and Kashmir',\u001b[39m\n\u001b[31m    'hp':'Himachal Pradesh','himachal':'Himachal Pradesh','himachalpradesh':'Himachal Pradesh',\u001b[39m\n\u001b[31m    'uk':'Uttarakhand','uttarakhand':'Uttarakhand','uttaranchal':'Uttarakhand',\u001b[39m\n\u001b[31m    'punjab':'Punjab','chandigarh':'Chandigarh','haryana':'Haryana','delhi':'Delhi',\u001b[39m\n\u001b[31m    'rajasthan':'Rajasthan','jharkhand':'Jharkhand','ladakh':'Ladakh',\u001b[39m\n\u001b[31m  };\u001b[39m\n\u001b[31m  return M[k] || String(s).trim();\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction sameState(a, b) {\u001b[39m\n\u001b[31m  if (!a || !b) return false;\u001b[39m\n\u001b[31m  return normState(a).toLowerCase() === normState(b).toLowerCase();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Geocode ONE stop with confidence flagging. Backward-compatible: when the sheet\u001b[39m\n\u001b[31m// has no STATE column (or a row is blank), it degrades to a single plain geocode\u001b[39m\n\u001b[31m// exactly like before. New fields (location_type/state) are guarded — if a\u001b[39m\n\u001b[31m// provider doesn't return them, we simply don't flag (no false alarms, no break).\u001b[39m\n\u001b[31masync function geocodeStopWithFlag(s) {\u001b[39m\n\u001b[31m  const fileState = (s.state || '').trim();\u001b[39m\n\u001b[31m  const stateNorm = normState(fileState);\u001b[39m\n\u001b[31m  const baseQ = s.address;                               // addr + city (today's query)\u001b[39m\n\u001b[31m  const withQ = stateNorm ? `${baseQ}, ${stateNorm}` : baseQ;·\u001b[39m\n\u001b[31m  let rw = null, ro = null;\u001b[39m\n\u001b[31m  try { rw = await api('/geocode', { address: withQ }); } catch (e) { rw = { ok:false, error:e.message }; }\u001b[39m\n\u001b[31m  // Only do the 2nd (without-state) call when there IS a state to cross-check.\u001b[39m\n\u001b[31m  if (stateNorm) {\u001b[39m\n\u001b[31m    await sleep(60);\u001b[39m\n\u001b[31m    try { ro = await api('/geocode', { address: baseQ }); } catch (e) { ro = { ok:false }; }\u001b[39m\n\u001b[31m  }·\u001b[39m\n\u001b[31m  const primary = (rw && rw.ok && rw.location) ? rw\u001b[39m\n\u001b[31m                : (ro && ro.ok && ro.location) ? ro : null;\u001b[39m\n\u001b[31m  if (!primary) { s.geocodeErr = (rw && rw.error) || 'No match'; return; }\u001b[39m\n\u001b[31m  s.location = primary.location;\u001b[39m\n\u001b[31m  s.provider = primary.provider;\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;   // reset (re-geocode safe)·\u001b[39m\n\u001b[31m  // ── Class 1 — WRONG PLACE: without-state landed in a different state ──\u001b[39m\n\u001b[31m  if (stateNorm && ro && ro.ok && ro.location && ro.state && !sameState(ro.state, fileState)) {\u001b[39m\n\u001b[31m    s.geoFlag = 'ambiguous';\u001b[39m\n\u001b[31m    s.geoMsg  = `Auto-corrected to ${stateNorm} — \\\"${s.city || baseQ}\\\" also exists in ${ro.state}.`;\u001b[39m\n\u001b[31m    s.geoCandidates = [\u001b[39m\n\u001b[31m      { label: `✓ ${stateNorm}`,            location: rw.location, formatted: rw.formatted_address || '' },\u001b[39m\n\u001b[31m      { label: `${ro.state} (rejected)`,    location: ro.location, formatted: ro.formatted_address || '' },\u001b[39m\n\u001b[31m    ];\u001b[39m\n\u001b[31m    s.provider = 'auto-state';\u001b[39m\n\u001b[31m    return;   // wrong-place outranks imprecise\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Unverified — sheet has a STATE column but this row is blank ──\u001b[39m\n\u001b[31m  if (_parsedCols && _parsedCols.state && !fileState) {\u001b[39m\n\u001b[31m    s.geoFlag = 'unverified';\u001b[39m\n\u001b[31m    s.geoMsg  = 'No state given — location not cross-checked.';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  // ── Class 2 — IMPRECISE: Google returned an area/locality centroid ──\u001b[39m\n\u001b[31m  if (primary.location_type === 'APPROXIMATE') {\u001b[39m\n\u001b[31m    s.geoFlag = 'approximate';\u001b[39m\n\u001b[31m    s.geoMsg  = 'Approximate — area centroid, not the exact shop.';\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  // Decouple display label from geocode query. If the user left the default\u001b[39m\n\u001b[31m  // (or cleared it), geocode the exact Plus Code but show the friendly label.\u001b[39m\n\u001b[31m  // Any custom text the user typed is used verbatim for both.\u001b[39m\n\u001b[31m  const typedOrigin = document.getElementById('inp-origin').value.trim();\u001b[39m\n\u001b[31m  if (!typedOrigin || typedOrigin === DEFAULT_ORIGIN_LABEL) {\u001b[39m\n\u001b[31m    state.origin.text  = DEFAULT_ORIGIN_QUERY;\u001b[39m\n\u001b[31m    state.origin.label = DEFAULT_ORIGIN_LABEL;\u001b[39m\n\u001b[31m  } else {\u001b[39m\n\u001b[31m    state.origin.text  = typedOrigin;\u001b[39m\n\u001b[31m    state.origin.label = typedOrigin;\u001b[39m\n\u001b[31m  }\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    await geocodeStopWithFlag(s);   // dual geocode + confidence flags (degrades to single)\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(60);\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 — switch progress bar to indeterminate shimmer so it's clear work is ongoing\u001b[39m\n\u001b[31m  // (a static 100% bar looks \\\"done\\\"; this makes it visually obvious the API call is running)\u001b[39m\n\u001b[31m  const progBar = document.getElementById('prog-bar');\u001b[39m\n\u001b[31m  progBar.classList.add('indeterminate');\u001b[39m\n\u001b[31m  setProgress(total, total, good.length > 20\u001b[39m\n\u001b[31m    ? `Optimising ${good.length} stops — large lists can take up to a minute…`\u001b[39m\n\u001b[31m    : 'Optimising route…');\u001b[39m\n\u001b[31m  startOptWait();\u001b[39m\n\u001b[31m  const okOpt = await optimiseAndShowEditor();\u001b[39m\n\u001b[31m  stopOptWait();\u001b[39m\n\u001b[31m  progBar.classList.remove('indeterminate');\u001b[39m\n\u001b[31m  document.getElementById('upload-progress').style.display = 'none';\u001b[39m\n\u001b[31m  if (okOpt && !_loadedFromUpload) autoSaveUpload();   // auto-save geocoded stops to DB\u001b[39m\n\u001b[31m  if (!okOpt) document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Shared optimise→editor tail used by BOTH the Excel flow and the manual planner.\u001b[39m\n\u001b[31m// Pre-conditions: state.origin.location set, state.stops already geocoded (each\u001b[39m\n\u001b[31m// has .location), state.destination + state.roundTrip set. Returns true on\u001b[39m\n\u001b[31m// success (lands on the editor), false on failure (toasts; caller owns its own\u001b[39m\n\u001b[31m// progress chrome). Behaviour is identical to the previous inline block.\u001b[39m\n\u001b[31masync function optimiseAndShowEditor() {\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    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    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) { s.legDistance_m = leg.distance_m; s.legDuration_s = leg.duration_s; }\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Optimise failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m    return false;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  showScreen('s-editor');\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  return true;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// ── Manual tour planner (s-manual) ───────────────────────────────\u001b[39m\n\u001b[31m// Build a tour by hand: set a start point, add destinations one-by-one via the\u001b[39m\n\u001b[31m// shared map picker, then \\\"Find optimized path\\\" → the SAME editor as Excel.\u001b[39m\n\u001b[31mfunction enterManual() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Start a new manual tour? Current unsaved stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  // Default start = office (known coords → no geocode call); fully changeable.\u001b[39m\n\u001b[31m  state.origin = { text: DEFAULT_ORIGIN_QUERY, label: DEFAULT_ORIGIN_LABEL, location: { ...DEFAULT_ORIGIN_LOC }, provider: 'plus-code' };\u001b[39m\n\u001b[31m  state.roundTrip = true; state.destination = null; state._destLabel = '';\u001b[39m\n\u001b[31m  showScreen('s-manual');\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualBack() {\u001b[39m\n\u001b[31m  if (state.stops.length && !confirm('Leave the manual planner? Added stops will be cleared.')) return;\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManual() {\u001b[39m\n\u001b[31m  document.getElementById('manual-origin-val').textContent = originLabel();\u001b[39m\n\u001b[31m  document.getElementById('manual-roundtrip').checked = !!state.roundTrip;\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-wrap').style.display = state.roundTrip ? 'none' : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-dest-val').textContent = state._destLabel || '— tap Set —';\u001b[39m\n\u001b[31m  const n = state.stops.length;\u001b[39m\n\u001b[31m  document.getElementById('manual-count').textContent = n ? `· ${n}${state.roundTrip ? ' · round-trip' : ''}` : '';\u001b[39m\n\u001b[31m  document.getElementById('manual-empty').style.display = n ? 'none' : '';\u001b[39m\n\u001b[31m  renderManualStops();\u001b[39m\n\u001b[31m  document.getElementById('manual-find').disabled = n < 1 || !state.origin.location;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderManualStops() {\u001b[39m\n\u001b[31m  const cont = document.getElementById('manual-stops');\u001b[39m\n\u001b[31m  cont.innerHTML = '';\u001b[39m\n\u001b[31m  state.stops.forEach((s, idx) => {\u001b[39m\n\u001b[31m    const el = document.createElement('div');\u001b[39m\n\u001b[31m    el.className = 'tile';\u001b[39m\n\u001b[31m    const sub = s.location ? `${s.location.lat.toFixed(4)}, ${s.location.lng.toFixed(4)}` : '⚠ no location';\u001b[39m\n\u001b[31m    el.innerHTML = `\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)}</div>\u001b[39m\n\u001b[31m        <div class=\\\"addr\\\">${escHtml(sub)}</div>\u001b[39m\n\u001b[31m      </div>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualEditStop('${s.id}')\\\" title=\\\"Edit this stop on the map\\\">📍</button>\u001b[39m\n\u001b[31m      <button class=\\\"fixloc\\\" onclick=\\\"manualRemoveStop('${s.id}')\\\" title=\\\"Remove this stop\\\" style=\\\"border-color:rgba(239,68,68,.5);color:#fca5a5\\\">🗑</button>`;\u001b[39m\n\u001b[31m    cont.appendChild(el);\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualToggleRoundtrip() {\u001b[39m\n\u001b[31m  state.roundTrip = document.getElementById('manual-roundtrip').checked;\u001b[39m\n\u001b[31m  if (state.roundTrip) { state.destination = null; state._destLabel = ''; }\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickOrigin() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '📍 Set start point',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place or drag the pin to where the tour starts',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 14,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const label = name || `Custom start (${lat.toFixed(4)}, ${lng.toFixed(4)})`;\u001b[39m\n\u001b[31m      state.origin = { text: label, label, location: { lat, lng }, provider: 'manual' };\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Start point set ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualPickDest() {\u001b[39m\n\u001b[31m  const o = state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'pick-origin',\u001b[39m\n\u001b[31m    title: '🏁 Set final destination',\u001b[39m\n\u001b[31m    nameHtml: 'Where should the one-way tour END?',\u001b[39m\n\u001b[31m    startLoc: [o.lat, o.lng], startZoom: 12,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      state.destination = `${lat},${lng}`;\u001b[39m\n\u001b[31m      state._destLabel  = name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualAddStop() {\u001b[39m\n\u001b[31m  if (state.stops.length >= 60) return toast('Max 60 stops', 'err');\u001b[39m\n\u001b[31m  const last = state.stops[state.stops.length - 1];\u001b[39m\n\u001b[31m  const ctr  = (last && last.location) || state.origin.location || DEFAULT_ORIGIN_LOC;\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '➕ Add destination',\u001b[39m\n\u001b[31m    nameHtml: 'Search a place, or drag the pin to the stop',\u001b[39m\n\u001b[31m    startLoc: [ctr.lat, ctr.lng], startZoom: 13,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      const stop = makeManualStop({\u001b[39m\n\u001b[31m        name: name || `Stop ${state.stops.length + 1}`,\u001b[39m\n\u001b[31m        location: { lat, lng },\u001b[39m\n\u001b[31m        provider: name ? 'search' : 'manual',\u001b[39m\n\u001b[31m      });\u001b[39m\n\u001b[31m      state.stops.push(stop);\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m      toast('Stop added ✓', 'ok');\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualEditStop(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng] : [DEFAULT_ORIGIN_LOC.lat, DEFAULT_ORIGIN_LOC.lng];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose: 'add-stop',\u001b[39m\n\u001b[31m    title: '📍 Edit stop',\u001b[39m\n\u001b[31m    nameHtml: escHtml(s.name),\u001b[39m\n\u001b[31m    searchPrefill: (s.name && !/^Stop \\\\d+$/.test(s.name)) ? s.name : '',\u001b[39m\n\u001b[31m    startLoc: ll, startZoom: 16,\u001b[39m\n\u001b[31m    onPick: ({ lat, lng, name }) => {\u001b[39m\n\u001b[31m      s.location = { lat, lng };\u001b[39m\n\u001b[31m      if (name) { s.name = name; s.hasExplicitName = true; }\u001b[39m\n\u001b[31m      renderManual();\u001b[39m\n\u001b[31m    },\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualRemoveStop(id) {\u001b[39m\n\u001b[31m  state.stops = state.stops.filter(s => s.id !== id);\u001b[39m\n\u001b[31m  renderManual();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction manualUseMyLocation() {\u001b[39m\n\u001b[31m  if (!navigator.geolocation) return toast('Geolocation not supported on this device', 'err');\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-gps');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '📍 Locating…';\u001b[39m\n\u001b[31m  navigator.geolocation.getCurrentPosition(async (pos) => {\u001b[39m\n\u001b[31m    const loc = { lat: pos.coords.latitude, lng: pos.coords.longitude };\u001b[39m\n\u001b[31m    let label = `My location (${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)})`;\u001b[39m\n\u001b[31m    // Reverse-geocode for a readable name if the proxy supports it (added in S2);\u001b[39m\n\u001b[31m    // gracefully falls back to coordinates if /reverse isn't available yet.\u001b[39m\n\u001b[31m    try { const r = await api('/reverse', loc); if (r && r.ok && r.formatted_address) label = r.formatted_address; } catch (_) {}\u001b[39m\n\u001b[31m    state.origin = { text: label, label, location: loc, provider: 'gps' };\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    renderManual();\u001b[39m\n\u001b[31m    toast('Start set to your location ✓', 'ok');\u001b[39m\n\u001b[31m  }, (err) => {\u001b[39m\n\u001b[31m    btn.disabled = false; btn.textContent = '📍 Use my current location';\u001b[39m\n\u001b[31m    toast('Location failed: ' + (err.message || 'permission denied'), 'err');\u001b[39m\n\u001b[31m  }, { enableHighAccuracy: true, timeout: 10000 });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function manualFindOptimized() {\u001b[39m\n\u001b[31m  if (!state.origin.location) return toast('Set a start point first', 'err');\u001b[39m\n\u001b[31m  const good = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!good.length) return toast('Add at least one destination', 'err');\u001b[39m\n\u001b[31m  state.stops = good;\u001b[39m\n\u001b[31m  if (state.roundTrip) state.destination = null;\u001b[39m\n\u001b[31m  const btn = document.getElementById('manual-find');\u001b[39m\n\u001b[31m  btn.disabled = true; btn.textContent = '⏳ Optimising…';\u001b[39m\n\u001b[31m  toast(good.length > 20 ? `Optimising ${good.length} stops — up to a minute…` : 'Optimising route…', 'ok');\u001b[39m\n\u001b[31m  const ok = await optimiseAndShowEditor();   // on success → s-editor (all options); failure → toast\u001b[39m\n\u001b[31m  btn.disabled = false; btn.textContent = '✓ Find optimized path';\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[31mlet _optWaitTimer = null;\u001b[39m\n\u001b[31mfunction startOptWait() {\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  el.style.display = 'flex';\u001b[39m\n\u001b[31m  let secs = 0;\u001b[39m\n\u001b[31m  const elapsedEl = document.getElementById('opt-elapsed');\u001b[39m\n\u001b[31m  const dotsEl    = document.getElementById('opt-dots');\u001b[39m\n\u001b[31m  const dotFrames  = ['', '.', '..', '...'];\u001b[39m\n\u001b[31m  _optWaitTimer = setInterval(() => {\u001b[39m\n\u001b[31m    secs++;\u001b[39m\n\u001b[31m    if (elapsedEl) elapsedEl.textContent = secs + 's';\u001b[39m\n\u001b[31m    if (dotsEl) dotsEl.textContent = dotFrames[secs % dotFrames.length];\u001b[39m\n\u001b[31m  }, 1000);\u001b[39m\n\u001b[31m}\u001b[39m\n\u001b[31mfunction stopOptWait() {\u001b[39m\n\u001b[31m  if (_optWaitTimer) { clearInterval(_optWaitTimer); _optWaitTimer = null; }\u001b[39m\n\u001b[31m  const el = document.getElementById('opt-wait');\u001b[39m\n\u001b[31m  if (el) el.style.display = 'none';\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 = originLabel();\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  updateFlagSummary();\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 updateFlagSummary() {\u001b[39m\n\u001b[31m  const el = document.getElementById('flag-summary');\u001b[39m\n\u001b[31m  if (!el) return;\u001b[39m\n\u001b[31m  const review = state.stops.filter(s => s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified').length;\u001b[39m\n\u001b[31m  const approx = state.stops.filter(s => s.geoFlag === 'approximate').length;\u001b[39m\n\u001b[31m  if (!review && !approx) { el.className = 'flag-summary'; el.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  const parts = [];\u001b[39m\n\u001b[31m  if (review) parts.push(`<span class=\\\"loud\\\">⚠ ${review} need${review === 1 ? 's' : ''} review</span>`);\u001b[39m\n\u001b[31m  if (approx) parts.push(`<span class=\\\"soft\\\">≈ ${approx} approximate</span>`);\u001b[39m\n\u001b[31m  el.innerHTML = parts.join(' &nbsp;·&nbsp; ') + ` &nbsp;— tap a flagged stop's 📍 to fix`;\u001b[39m\n\u001b[31m  el.className = 'flag-summary show';\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  // Loud styling only for the high-severity \\\"ambiguous\\\" (wrong-state) flag.\u001b[39m\n\u001b[31m  const loud = s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified';\u001b[39m\n\u001b[31m  el.className = 'tile' + (s.included ? '' : ' excluded') + (s.geocodeErr ? ' err' : '') + (loud ? ' geowarn' : '');\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  // Geocode-confidence line: loud ⚠ for ambiguous/unverified, subtle ≈ for approximate.\u001b[39m\n\u001b[31m  let geoLine = '';\u001b[39m\n\u001b[31m  if (s.geoFlag === 'ambiguous' || s.geoFlag === 'unverified') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag loud\\\" onclick=\\\"openFixLoc('${s.id}')\\\">⚠ ${escHtml(s.geoMsg)} <u>Tap to ${s.geoFlag === 'ambiguous' ? 'review' : 'verify'}</u></div>`;\u001b[39m\n\u001b[31m  } else if (s.geoFlag === 'approximate') {\u001b[39m\n\u001b[31m    geoLine = `<div class=\\\"geo-flag soft\\\" onclick=\\\"openFixLoc('${s.id}')\\\">≈ ${escHtml(s.geoMsg)} <u>Tap to refine</u></div>`;\u001b[39m\n\u001b[31m  }\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      ${geoLine}\u001b[39m\n\u001b[31m    </div>\u001b[39m\n\u001b[31m    <button class=\\\"fixloc\\\" onclick=\\\"openFixLoc('${s.id}')\\\" title=\\\"Fix this stop's location on the map\\\">📍</button>\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: ' + originLabel(), { 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// ── Fix location (manual geocode correction) ─────────────────────\u001b[39m\n\u001b[31m// Lets the user correct a wrong/imprecise pin: a draggable Leaflet marker on\u001b[39m\n\u001b[31m// OSM (with optional Esri satellite for building recognition) + a search box\u001b[39m\n\u001b[31m// that uses the existing /geocode proxy to jump to the right area. The marker\u001b[39m\n\u001b[31m// position is the source of truth; we save raw lat/lng (no reverse-geocode).\u001b[39m\n\u001b[31mlet _fixMap = null, _fixMarker = null, _fixStopId = null;\u001b[39m\n\u001b[31mlet _fixOSM = null, _fixSat = null, _fixUsingSat = false;\u001b[39m\n\u001b[31m// Picker purpose (S0 groundwork for the manual planner): 'edit-stop' (default,\u001b[39m\n\u001b[31m// writes back to a stop — v17/v18 behaviour) | 'pick-origin' | 'add-stop' (both\u001b[39m\n\u001b[31m// hand the chosen {location,name} to _fixOnPick instead of mutating a stop).\u001b[39m\n\u001b[31mlet _fixPurpose = 'edit-stop', _fixOnPick = null;\u001b[39m\n\u001b[31mlet _fixCandidates = null;     // candidate chips array (edit-stop ambiguous)\u001b[39m\n\u001b[31mlet _fixLastName = '';         // formatted_address of the last successful search (for naming picked places)·\u001b[39m\n\u001b[31m// Edit an existing stop's location (v17/v18 behaviour) — thin wrapper over the\u001b[39m\n\u001b[31m// generalized picker. Unchanged user-visible behaviour.\u001b[39m\n\u001b[31mfunction openFixLoc(id) {\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === id);\u001b[39m\n\u001b[31m  if (!s) return;\u001b[39m\n\u001b[31m  const ll = s.location ? [s.location.lat, s.location.lng]\u001b[39m\n\u001b[31m           : state.origin?.location ? [state.origin.location.lat, state.origin.location.lng]\u001b[39m\n\u001b[31m           : [22.5, 79.0];\u001b[39m\n\u001b[31m  openPicker({\u001b[39m\n\u001b[31m    purpose:      'edit-stop',\u001b[39m\n\u001b[31m    stopId:       id,\u001b[39m\n\u001b[31m    title:        '📍 Fix location',\u001b[39m\n\u001b[31m    nameHtml:     `${escHtml(s.name)}<small>${escHtml(s.city || '')}${s.provider ? ' · was: ' + escHtml(s.provider) : ''}</small>`,\u001b[39m\n\u001b[31m    searchPrefill:[s.name, s.city].filter(Boolean).join(', '),\u001b[39m\n\u001b[31m    candidates:   s.geoCandidates,\u001b[39m\n\u001b[31m    startLoc:     ll,\u001b[39m\n\u001b[31m    startZoom:    s.location ? 16 : 5,\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Generalized place picker. Drives the SAME map/search/satellite modal for:\u001b[39m\n\u001b[31m//  • 'edit-stop'  → saveFixLoc writes back to the stop (editor flow)\u001b[39m\n\u001b[31m//  • 'pick-origin'/'add-stop' → saveFixLoc hands {lat,lng,name} to opts.onPick\u001b[39m\n\u001b[31mfunction openPicker(opts) {\u001b[39m\n\u001b[31m  _fixPurpose  = opts.purpose || 'edit-stop';\u001b[39m\n\u001b[31m  _fixOnPick   = opts.onPick || null;\u001b[39m\n\u001b[31m  _fixStopId   = opts.stopId || null;\u001b[39m\n\u001b[31m  _fixLastName = '';\u001b[39m\n\u001b[31m  document.querySelector('#modal-fixloc .modal-ttl').textContent = opts.title || '📍 Fix location';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-name').innerHTML   = opts.nameHtml || '';\u001b[39m\n\u001b[31m  document.getElementById('fixloc-search-inp').value = opts.searchPrefill || '';\u001b[39m\n\u001b[31m  renderFixCandidates(opts.candidates || null);\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.add('show');\u001b[39m\n\u001b[31m  const ll   = opts.startLoc || [22.5, 79.0];\u001b[39m\n\u001b[31m  const zoom = opts.startZoom || 5;\u001b[39m\n\u001b[31m  // Leaflet must size after the modal is visible (div was 0×0 while hidden).\u001b[39m\n\u001b[31m  setTimeout(() => {\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.invalidateSize();\u001b[39m\n\u001b[31m    _fixMap.setView(ll, zoom);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng(ll);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  }, 80);\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderFixCandidates(candidates) {\u001b[39m\n\u001b[31m  const box = document.getElementById('fixloc-candidates');\u001b[39m\n\u001b[31m  _fixCandidates = (candidates && candidates.length) ? candidates : null;\u001b[39m\n\u001b[31m  if (!_fixCandidates) { box.style.display = 'none'; box.innerHTML = ''; return; }\u001b[39m\n\u001b[31m  let html = `<div class=\\\"ttl\\\">⚠ ambiguous — pick the correct location:</div>`;\u001b[39m\n\u001b[31m  _fixCandidates.forEach((c, i) => {\u001b[39m\n\u001b[31m    html += `<button class=\\\"opt\\\" onclick=\\\"pickFixCandidate(${i})\\\">\u001b[39m\n\u001b[31m      <span style=\\\"font-size:15px\\\">📍</span>\u001b[39m\n\u001b[31m      <span><b>${escHtml(c.label)}</b><small>${escHtml(c.formatted || '')}</small></span>\u001b[39m\n\u001b[31m    </button>`;\u001b[39m\n\u001b[31m  });\u001b[39m\n\u001b[31m  box.innerHTML = html;\u001b[39m\n\u001b[31m  box.style.display = '';\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31m// Move the draggable pin onto the chosen candidate.\u001b[39m\n\u001b[31mfunction pickFixCandidate(i) {\u001b[39m\n\u001b[31m  if (!_fixCandidates || !_fixCandidates[i]) return;\u001b[39m\n\u001b[31m  const c = _fixCandidates[i];\u001b[39m\n\u001b[31m  ensureFixMap();\u001b[39m\n\u001b[31m  _fixMap.setView([c.location.lat, c.location.lng], 15);\u001b[39m\n\u001b[31m  _fixMarker.setLatLng([c.location.lat, c.location.lng]);\u001b[39m\n\u001b[31m  updateFixCoords();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction ensureFixMap() {\u001b[39m\n\u001b[31m  if (_fixMap) return _fixMap;\u001b[39m\n\u001b[31m  _fixMap = L.map('fixloc-map', { attributionControl: true, zoomControl: true });\u001b[39m\n\u001b[31m  _fixOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: '© OpenStreetMap' });\u001b[39m\n\u001b[31m  _fixSat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{x}/{y}',\u001b[39m\n\u001b[31m    { maxZoom: 19, attribution: 'Tiles © Esri' });\u001b[39m\n\u001b[31m  _fixOSM.addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker = L.marker([22.5, 79.0], {\u001b[39m\n\u001b[31m    draggable: true,\u001b[39m\n\u001b[31m    icon: L.divIcon({ className: 'fixloc-pin', html: '<div>📍</div>', iconSize: [30, 30], iconAnchor: [15, 28] }),\u001b[39m\n\u001b[31m  }).addTo(_fixMap);\u001b[39m\n\u001b[31m  _fixMarker.on('dragend', updateFixCoords);\u001b[39m\n\u001b[31m  // Tap anywhere on the map to drop the pin there.\u001b[39m\n\u001b[31m  _fixMap.on('click', (e) => { _fixMarker.setLatLng(e.latlng); updateFixCoords(); });\u001b[39m\n\u001b[31m  return _fixMap;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateFixCoords() {\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  document.getElementById('fixloc-coords').textContent = `${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function fixLocSearch() {\u001b[39m\n\u001b[31m  const q = document.getElementById('fixloc-search-inp').value.trim();\u001b[39m\n\u001b[31m  if (!q) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/geocode', { address: q });\u001b[39m\n\u001b[31m    if (!r.ok || !r.location) throw new Error(r.error || 'No match');\u001b[39m\n\u001b[31m    _fixLastName = r.formatted_address || q;   // remember for naming picked places\u001b[39m\n\u001b[31m    ensureFixMap();\u001b[39m\n\u001b[31m    _fixMap.setView([r.location.lat, r.location.lng], 16);\u001b[39m\n\u001b[31m    _fixMarker.setLatLng([r.location.lat, r.location.lng]);\u001b[39m\n\u001b[31m    updateFixCoords();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Search failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction toggleFixLayer() {\u001b[39m\n\u001b[31m  if (!_fixMap) return;\u001b[39m\n\u001b[31m  _fixUsingSat = !_fixUsingSat;\u001b[39m\n\u001b[31m  const btn = document.getElementById('fixloc-layer');\u001b[39m\n\u001b[31m  if (_fixUsingSat) { _fixMap.removeLayer(_fixOSM); _fixSat.addTo(_fixMap); btn.classList.add('on'); btn.textContent = '🗺 Map'; }\u001b[39m\n\u001b[31m  else              { _fixMap.removeLayer(_fixSat); _fixOSM.addTo(_fixMap); btn.classList.remove('on'); btn.textContent = '🛰 Satellite'; }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction saveFixLoc() {\u001b[39m\n\u001b[31m  // Non-edit purposes (pick-origin / add-stop) hand the chosen location to a\u001b[39m\n\u001b[31m  // callback instead of mutating a stop. (Wired up by the manual planner in S1.)\u001b[39m\n\u001b[31m  if (_fixPurpose !== 'edit-stop') {\u001b[39m\n\u001b[31m    if (!_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m    const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m    const cb = _fixOnPick;\u001b[39m\n\u001b[31m    const nm = _fixLastName;\u001b[39m\n\u001b[31m    closeFixLoc();\u001b[39m\n\u001b[31m    if (cb) cb({ lat: ll.lat, lng: ll.lng, name: nm || '' });\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const s = state.stops.find(x => x.id === _fixStopId);\u001b[39m\n\u001b[31m  if (!s || !_fixMarker) return closeFixLoc();\u001b[39m\n\u001b[31m  const ll = _fixMarker.getLatLng();\u001b[39m\n\u001b[31m  s.location   = { lat: ll.lat, lng: ll.lng };\u001b[39m\n\u001b[31m  s.provider   = 'manual';   // flips the tile's provider badge to \\\"manual\\\"\u001b[39m\n\u001b[31m  s.geocodeErr = null;\u001b[39m\n\u001b[31m  // Human resolved the location → clear any confidence flag/candidates.\u001b[39m\n\u001b[31m  s.geoFlag = null; s.geoMsg = ''; s.geoCandidates = null;\u001b[39m\n\u001b[31m  // Moving a stop changes leg distances → the route may no longer be optimal.\u001b[39m\n\u001b[31m  // Same UX as include/exclude: flag stale + prompt Re-optim; recalc current cost.\u001b[39m\n\u001b[31m  state.needsReoptimize = true;\u001b[39m\n\u001b[31m  closeFixLoc();\u001b[39m\n\u001b[31m  renderEditor();\u001b[39m\n\u001b[31m  scheduleRecalc();\u001b[39m\n\u001b[31m  toast('Location updated ✓ — tap 🔁 Re-optim to refresh the route', 'ok');\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction closeFixLoc() {\u001b[39m\n\u001b[31m  document.getElementById('modal-fixloc').classList.remove('show');\u001b[39m\n\u001b[31m  _fixStopId = null;\u001b[39m\n\u001b[31m  _fixPurpose = 'edit-stop'; _fixOnPick = null;   // reset so callbacks can't leak\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 = originLabel();\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  // Unchanged from what's shown → keep the already-geocoded origin (don't\u001b[39m\n\u001b[31m  // re-geocode the friendly label, which isn't the geocode query).\u001b[39m\n\u001b[31m  if (txt === originLabel() && state.origin.location) {\u001b[39m\n\u001b[31m    closeOriginEdit();\u001b[39m\n\u001b[31m    await replanAll();\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    // Map the default label back to its Plus Code; custom text geocodes verbatim.\u001b[39m\n\u001b[31m    const query = (txt === DEFAULT_ORIGIN_LABEL) ? DEFAULT_ORIGIN_QUERY : txt;\u001b[39m\n\u001b[31m    const og = await api('/geocode', { address: query });\u001b[39m\n\u001b[31m    if (!og.ok) throw new Error('Origin not found');\u001b[39m\n\u001b[31m    state.origin = { text: query, label: 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(included.length > 20 ? `Optimising ${included.length} stops — up to a minute…` : 'Optimising…', 'ok');\u001b[39m\n\u001b[31m  // Show the inline spinner so a long (30–40s) large-list solve doesn't look frozen.\u001b[39m\n\u001b[31m  document.getElementById('recalc').classList.add('show');\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  } 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[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: ${originLabel()}\\\\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 ${originLabel()}`;\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// ── DB History ──────────────────────────────────────·\u001b[39m\n\u001b[31m// Auto-save geocoded stops after a successful optimise run (fire-and-forget).\u001b[39m\n\u001b[31m// Skipped when stops were loaded from a DB upload (_loadedFromUpload=true — already saved).\u001b[39m\n\u001b[31masync function autoSaveUpload() {\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m      result_json: state.proposedOrder\u001b[39m\n\u001b[31m        ? { proposedOrder: state.proposedOrder, currentTotal: state.currentTotal, baseTotal: state.baseTotal }\u001b[39m\n\u001b[31m        : null,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    if (r?.id && _batchGroupIdx !== null) await markGroupPlanned(r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    console.warn('[auto-save upload]', e.message);\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiGet(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (r.status === 401) {\u001b[39m\n\u001b[31m    toast('Session expired — please re-login', 'err');\u001b[39m\n\u001b[31m    setTimeout(() => location.href = '/hub/?next=' + encodeURIComponent(location.pathname), 1500);\u001b[39m\n\u001b[31m    throw new Error('unauthorized');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m  return r.json();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function apiDel(path) {\u001b[39m\n\u001b[31m  const r = await fetch(PROXY + path, { method: 'DELETE', headers: { 'Authorization': hubAuth() } });\u001b[39m\n\u001b[31m  if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`${r.status} ${t.slice(0, 80)}`); }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction fmtDate(iso) {\u001b[39m\n\u001b[31m  return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', dateStyle: 'medium', timeStyle: 'short' });\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveUploadToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('Geocode stops first (click Parse + optimise), then save', 'err');\u001b[39m\n\u001b[31m  const geocoded = state.stops.filter(s => s.location);\u001b[39m\n\u001b[31m  if (!geocoded.length) return toast('No geocoded stops to save', 'err');\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/uploads', {\u001b[39m\n\u001b[31m      filename: _currentFilename || 'upload.xlsx',\u001b[39m\n\u001b[31m      col_map: _parsedCols || {},\u001b[39m\n\u001b[31m      stops: geocoded,\u001b[39m\n\u001b[31m    });\u001b[39m\n\u001b[31m    toast('Stops saved ✓ (ID ' + r.id + ')', 'ok');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function saveTourToDB() {\u001b[39m\n\u001b[31m  if (!state.stops.length) return toast('No tour to save', 'err');\u001b[39m\n\u001b[31m  const title = (prompt('Name this tour (optional):', '') || '').trim();\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await api('/db/tours', { title, tour_state: state });\u001b[39m\n\u001b[31m    toast('Tour saved ✓' + (_batchGroupIdx !== null ? ' · batch updated' : ''), 'ok');\u001b[39m\n\u001b[31m    if (_batchGroupIdx !== null) await markGroupPlanned(null, r.id);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    toast('Save failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openHistory() {\u001b[39m\n\u001b[31m  showScreen('s-history');\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.add('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.remove('active');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = '';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = 'none';\u001b[39m\n\u001b[31m  await histLoadUploads();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histShowTab(tab) {\u001b[39m\n\u001b[31m  document.getElementById('tab-uploads').classList.toggle('active',  tab === 'uploads');\u001b[39m\n\u001b[31m  document.getElementById('tab-tours').classList.toggle('active',    tab === 'tours');\u001b[39m\n\u001b[31m  document.getElementById('tab-batches').classList.toggle('active',  tab === 'batches');\u001b[39m\n\u001b[31m  document.getElementById('hist-uploads-pane').style.display  = tab === 'uploads'  ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-tours-pane').style.display    = tab === 'tours'    ? '' : 'none';\u001b[39m\n\u001b[31m  document.getElementById('hist-batches-pane').style.display  = tab === 'batches'  ? '' : 'none';\u001b[39m\n\u001b[31m  if (tab === 'uploads') await histLoadUploads();\u001b[39m\n\u001b[31m  else if (tab === 'tours') await histLoadTours();\u001b[39m\n\u001b[31m  else await histLoadBatches();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadUploads() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-uploads-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/uploads');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved uploads yet.<br>After geocoding stops, use <b>📊 Save stops to account</b> in the Share menu.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">📊 ${escHtml(r.filename || 'upload.xlsx')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"openUploadPreview(${r.id})\\\">👁 View</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteUpload(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function histLoadTours() {\u001b[39m\n\u001b[31m  const el = document.getElementById('hist-tours-list');\u001b[39m\n\u001b[31m  el.innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const rows = await apiGet('/db/tours');\u001b[39m\n\u001b[31m    if (!rows.length) {\u001b[39m\n\u001b[31m      el.innerHTML = '<div class=\\\"empty\\\">No saved tours yet.<br>Use <b>🗺 Save tour plan to account</b> in the Share menu after optimising.</div>';\u001b[39m\n\u001b[31m      return;\u001b[39m\n\u001b[31m    }\u001b[39m\n\u001b[31m    el.innerHTML = rows.map(r => `\u001b[39m\n\u001b[31m      <div class=\\\"hist-item\\\">\u001b[39m\n\u001b[31m        <div class=\\\"hist-meta\\\">\u001b[39m\n\u001b[31m          <div class=\\\"hist-name\\\">🗺 ${escHtml(r.title || 'Untitled tour')}</div>\u001b[39m\n\u001b[31m          <div class=\\\"hist-sub\\\">${r.stop_count} stops${r.total_km ? ' · ' + Number(r.total_km).toFixed(1) + ' km' : ''} · ${fmtDate(r.created_at)}</div>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m        <div class=\\\"hist-actions\\\">\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn\\\" onclick=\\\"loadTour(${r.id})\\\">▶ Load</button>\u001b[39m\n\u001b[31m          <button class=\\\"hist-btn danger\\\" onclick=\\\"deleteTour(${r.id}, this)\\\">🗑</button>\u001b[39m\n\u001b[31m        </div>\u001b[39m\n\u001b[31m      </div>`).join('');\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    el.innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function openUploadPreview(id) {\u001b[39m\n\u001b[31m  showScreen('s-upload-preview');\u001b[39m\n\u001b[31m  document.getElementById('preview-title').textContent = 'Loading…';\u001b[39m\n\u001b[31m  document.getElementById('preview-sub').textContent = '';\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">Loading…</div>';\u001b[39m\n\u001b[31m  document.getElementById('btn-optimise-upload').disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const upload = await apiGet('/db/uploads/' + id);\u001b[39m\n\u001b[31m    _previewUpload = upload;\u001b[39m\n\u001b[31m    document.getElementById('preview-title').textContent = upload.filename || 'Saved Upload';\u001b[39m\n\u001b[31m    document.getElementById('preview-sub').textContent = `${upload.stop_count} geocoded stops · ${fmtDate(upload.created_at)}`;\u001b[39m\n\u001b[31m    document.getElementById('btn-optimise-upload').disabled = false;\u001b[39m\n\u001b[31m    renderUploadPreviewGrid(upload.stops);\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = `<div class=\\\"empty\\\" style=\\\"color:var(--r)\\\">Error: ${escHtml(e.message)}</div>`;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction renderUploadPreviewGrid(stops) {\u001b[39m\n\u001b[31m  if (!Array.isArray(stops) || !stops.length) {\u001b[39m\n\u001b[31m    document.getElementById('upload-preview-grid').innerHTML = '<div class=\\\"empty\\\">No stops in this upload</div>';\u001b[39m\n\u001b[31m    return;\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m  const rows = stops.map((s, i) => {\u001b[39m\n\u001b[31m    const geoCell = s.geocodeErr\u001b[39m\n\u001b[31m      ? `<span class=\\\"geo-err\\\" title=\\\"${escHtml(s.geocodeErr)}\\\">✗ failed</span>`\u001b[39m\n\u001b[31m      : s.geoFlag === 'ambiguous'\u001b[39m\n\u001b[31m        ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ checked</span>`\u001b[39m\n\u001b[31m        : s.geoFlag === 'approximate'\u001b[39m\n\u001b[31m          ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ approx</span>`\u001b[39m\n\u001b[31m          : s.geoFlag === 'unverified'\u001b[39m\n\u001b[31m            ? `<span class=\\\"geo-warn\\\" title=\\\"${escHtml(s.geoMsg)}\\\">⚠ unverif</span>`\u001b[39m\n\u001b[31m            : `<span class=\\\"geo-ok\\\">✓ ok</span>`;\u001b[39m\n\u001b[31m    const coord = s.location\u001b[39m\n\u001b[31m      ? `<span class=\\\"coord\\\">${s.location.lat.toFixed(4)},${s.location.lng.toFixed(4)}</span>`\u001b[39m\n\u001b[31m      : '—';\u001b[39m\n\u001b[31m    return `<tr>\u001b[39m\n\u001b[31m      <td style=\\\"color:var(--muted);font-weight:700;width:28px\\\">${i + 1}</td>\u001b[39m\n\u001b[31m      <td style=\\\"font-weight:700;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.name)}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap\\\">${escHtml(s.city || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.address || '')}</td>\u001b[39m\n\u001b[31m      <td style=\\\"max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)\\\">${escHtml(s.phone || '')}</td>\u001b[39m\n\u001b[31m      <td>${geoCell}</td>\u001b[39m\n\u001b[31m      <td>${coord}</td>\u001b[39m\n\u001b[31m    </tr>`;\u001b[39m\n\u001b[31m  }).join('');\u001b[39m\n\u001b[31m  document.getElementById('upload-preview-grid').innerHTML = `\u001b[39m\n\u001b[31m    <p style=\\\"font-size:11px;color:var(--muted);margin-bottom:8px\\\">Tap <b>⚡ Optimise</b> to load these stops into the planner. All stops are pre-geocoded — no extra geocoding needed.</p>\u001b[39m\n\u001b[31m    <div class=\\\"stops-grid-wrap\\\">\u001b[39m\n\u001b[31m      <table class=\\\"stops-grid\\\">\u001b[39m\n\u001b[31m        <thead><tr><th>#</th><th>Name</th><th>City</th><th>Address</th><th>Phone</th><th>Geocode</th><th>Coords</th></tr></thead>\u001b[39m\n\u001b[31m        <tbody>${rows}</tbody>\u001b[39m\n\u001b[31m      </table>\u001b[39m\n\u001b[31m    </div>`;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction optimiseLoadedUpload() {\u001b[39m\n\u001b[31m  if (!_previewUpload || !Array.isArray(_previewUpload.stops)) return toast('No upload loaded', 'err');\u001b[39m\n\u001b[31m  state = newState();\u001b[39m\n\u001b[31m  state.stops = _previewUpload.stops.map(s => ({ ...s, id: uid('st') }));\u001b[39m\n\u001b[31m  _parsedCols = _previewUpload.col_map || {};\u001b[39m\n\u001b[31m  _parsedRows = null;\u001b[39m\n\u001b[31m  _currentFilename = _previewUpload.filename || '';\u001b[39m\n\u001b[31m  _loadedFromUpload = true;\u001b[39m\n\u001b[31m  _loadedUploadFilename = _previewUpload.filename || 'saved upload';\u001b[39m\n\u001b[31m  showScreen('s-upload');\u001b[39m\n\u001b[31m  renderParsedPreview();\u001b[39m\n\u001b[31m  document.getElementById('btn-go').disabled = false;\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function deleteUpload(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved upload? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/uploads/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadUploads();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete failed: ' + e.message, 'err');\u001b[39m\n\u001b[31m  }\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31masync function loadTour(id) {\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    const r = await apiGet('/db/tours/' + id);\u001b[39m\n\u001b[31m    state = r.tour_state;\u001b[39m\n\u001b[31m    showScreen('s-editor');\u001b[39m\n\u001b[31m    renderEditor();\u001b[39m\n\u001b[31m    toast('Tour 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[31masync function deleteTour(id, btn) {\u001b[39m\n\u001b[31m  if (!confirm('Delete this saved tour? Cannot be undone.')) return;\u001b[39m\n\u001b[31m  btn.disabled = true;\u001b[39m\n\u001b[31m  try {\u001b[39m\n\u001b[31m    await apiDel('/db/tours/' + id);\u001b[39m\n\u001b[31m    toast('Deleted ✓', 'ok');\u001b[39m\n\u001b[31m    await histLoadTours();\u001b[39m\n\u001b[31m  } catch (e) {\u001b[39m\n\u001b[31m    btn.disabled = false;\u001b[39m\n\u001b[31m    toast('Delete 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  _loadedFromUpload = false;\u001b[39m\n\u001b[31m  _navStack = [];\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById('s-upload').classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\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  updateResumeBtn();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction editorBack() {\u001b[39m\n\u001b[31m  goBack();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction showScreen(id, push = true) {\u001b[39m\n\u001b[31m  const cur = document.querySelector('.scr.active');\u001b[39m\n\u001b[31m  const curId = cur?.id;\u001b[39m\n\u001b[31m  if (push && curId && curId !== id) {\u001b[39m\n\u001b[31m    _navStack.push(curId);\u001b[39m\n\u001b[31m    if (_navStack.length > 20) _navStack.shift();\u001b[39m\n\u001b[31m  }\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  updateResumeBtn();\u001b[39m\n\u001b[31m  if (id === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (id === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction goBack() {\u001b[39m\n\u001b[31m  const prev = _navStack.pop();\u001b[39m\n\u001b[31m  if (!prev) { showScreen('s-upload', false); return; }\u001b[39m\n\u001b[31m  document.querySelectorAll('.scr').forEach(s => s.classList.remove('active'));\u001b[39m\n\u001b[31m  document.getElementById(prev).classList.add('active');\u001b[39m\n\u001b[31m  window.scrollTo(0, 0);\u001b[39m\n\u001b[31m  updateResumeBtn();\u001b[39m\n\u001b[31m  if (prev === 's-batch-review' && _batchGroups.length) renderBatchReview();\u001b[39m\n\u001b[31m  if (prev === 's-group-editor' && _batchGroups.length) renderGroupEditor();\u001b[39m\n\u001b[31m}·\u001b[39m\n\u001b[31mfunction updateResumeBtn() {\u001b[39m\n\u001b[31m  const btn = document.getElementById('btn-resume');\u001b[39m\n\u001b[31m  if (btn) btn.style.display = (state.proposedOrder !== null) ? '' : 'none';\u001b[39m\n\u001b[31m  const btnNew = document.getElementById('btn-new');\u001b[39m\n\u001b[31m  if (btnNew) btnNew.style.display = (state.stops.length > 0) ? '' : 'none';\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  124 |   test('SortableJS + SheetJS CDN loaded', () => {\n  125 |     expect(html).toContain('sortablejs');\n> 126 |     expect(html).toContain('sheetjs');\n      |                  ^\n  127 |   });\n  128 |\n  129 |   test('Leaflet CDN + inline map widget present', () => {\n    at /var/www/360lm/tests/tour_pg.spec.js:126:18"
                        }
                      ],
                      "stdout": [],
                      "stderr": [],
                      "retry": 1,
                      "startTime": "2026-06-23T13:02:17.755Z",
                      "annotations": [],
                      "attachments": [
                        {
                          "name": "error-context",
                          "contentType": "text/markdown",
                          "path": "/var/www/360lm/test-results/tour_pg-Tour-Playground-—--b4648-rtableJS-SheetJS-CDN-loaded-desktop-chrome-retry1/error-context.md"
                        }
                      ],
                      "errorLocation": {
                        "file": "/var/www/360lm/tests/tour_pg.spec.js",
                        "column": 18,
                        "line": 126
                      }
                    }
                  ],
                  "status": "unexpected"
                }
              ],
              "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": 24,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:19.052Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ac9849b17b955308785e",
              "file": "tour_pg.spec.js",
              "line": 129,
              "column": 3
            },
            {
              "title": "manual planner screen + entry button + back/pill present",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 8,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:19.289Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-63441ac4b0f36122a81c",
              "file": "tour_pg.spec.js",
              "line": 139,
              "column": 3
            },
            {
              "title": "shared refactor functions present (S0)",
              "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-23T13:02:19.326Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-3add251077b2f20eea70",
              "file": "tour_pg.spec.js",
              "line": 150,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Auth",
          "file": "tour_pg.spec.js",
          "line": 160,
          "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": 596,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:54.914Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-83dd50fbaa77ebc47a5d",
              "file": "tour_pg.spec.js",
              "line": 161,
              "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": 948,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:55.649Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ac58e3c45c229c64a5c8",
              "file": "tour_pg.spec.js",
              "line": 170,
              "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": 965,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:56.629Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-b105e116251a5a974331",
              "file": "tour_pg.spec.js",
              "line": 179,
              "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": 741,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:19.366Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8a7b32ab13ba7e650d6d",
              "file": "tour_pg.spec.js",
              "line": 161,
              "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": 977,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:20.257Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-c08ca36371ec8fd17b5d",
              "file": "tour_pg.spec.js",
              "line": 170,
              "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": 900,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:21.262Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-debfcd19a58dd43fb67b",
              "file": "tour_pg.spec.js",
              "line": 179,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Proxy end-to-end",
          "file": "tour_pg.spec.js",
          "line": 190,
          "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": 2559,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:01:57.613Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-d9eb7610af698ae1097f",
              "file": "tour_pg.spec.js",
              "line": 191,
              "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": 795,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:00.188Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ae600ef94ad0ea6686b6",
              "file": "tour_pg.spec.js",
              "line": 238,
              "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": 2180,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:22.184Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-803599e44c1e6f67a1c1",
              "file": "tour_pg.spec.js",
              "line": 191,
              "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": 654,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:24.382Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-68347481273a6865f1ad",
              "file": "tour_pg.spec.js",
              "line": 238,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Browser JS state",
          "file": "tour_pg.spec.js",
          "line": 267,
          "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": 1859,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:00.998Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-1ad6d74924fbe20b2a74",
              "file": "tour_pg.spec.js",
              "line": 268,
              "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": 2016,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:02.911Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-d37ddfb5b63aa657d1cf",
              "file": "tour_pg.spec.js",
              "line": 276,
              "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": 2415,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:04.989Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-76a529476034b7654e66",
              "file": "tour_pg.spec.js",
              "line": 289,
              "column": 3
            },
            {
              "title": "manual planner: enter → add stops via factory → Find → editor",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1836,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:07.481Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-4969201a960fbf508039",
              "file": "tour_pg.spec.js",
              "line": 325,
              "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": 1560,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:09.344Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-fb5425d4cf28fdd89fa0",
              "file": "tour_pg.spec.js",
              "line": 362,
              "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": 1029,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:25.053Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-b55f22712ed011390d18",
              "file": "tour_pg.spec.js",
              "line": 268,
              "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": 1418,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:26.108Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-2542672225a60b02d394",
              "file": "tour_pg.spec.js",
              "line": 276,
              "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": 2095,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:27.559Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ee875d59fbce16fa3a8f",
              "file": "tour_pg.spec.js",
              "line": 289,
              "column": 3
            },
            {
              "title": "manual planner: enter → add stops via factory → Find → editor",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1969,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:29.686Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-3eed26b6c0e35f36d7c9",
              "file": "tour_pg.spec.js",
              "line": 325,
              "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": 1333,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:31.684Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ee0ea69eaa8d3fc3f0d7",
              "file": "tour_pg.spec.js",
              "line": 362,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Hub",
          "file": "tour_pg.spec.js",
          "line": 394,
          "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": 37,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:10.941Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-dbd8d78ec13d6ad2657d",
              "file": "tour_pg.spec.js",
              "line": 395,
              "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": 27,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:33.034Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-8827f07514089a554a45",
              "file": "tour_pg.spec.js",
              "line": 395,
              "column": 3
            }
          ]
        },
        {
          "title": "Tour Playground — Excel header-row detection",
          "file": "tour_pg.spec.js",
          "line": 421,
          "column": 6,
          "specs": [
            {
              "title": "findHeaderRowIndex skips title rows and finds real headers",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1405,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:11.010Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-dc2e3161aaff41137354",
              "file": "tour_pg.spec.js",
              "line": 422,
              "column": 3
            },
            {
              "title": "findHeaderRowIndex returns 0 for well-formed files (no title rows)",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1281,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:12.445Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-bad23d34ad3665a04928",
              "file": "tour_pg.spec.js",
              "line": 441,
              "column": 3
            },
            {
              "title": "source contains findHeaderRowIndex function",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 21,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:13.753Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-0d8675d8c0a33d8b0f92",
              "file": "tour_pg.spec.js",
              "line": 456,
              "column": 3
            },
            {
              "title": "cache version is v22 or later",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "android-chrome",
                  "projectName": "android-chrome",
                  "results": [
                    {
                      "workerIndex": 2,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 17,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:13.802Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-dbe9d43d0bf07ff3bb80",
              "file": "tour_pg.spec.js",
              "line": 464,
              "column": 3
            },
            {
              "title": "findHeaderRowIndex skips title rows and finds real headers",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1393,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:33.080Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-469f75b1ee2b672dcf48",
              "file": "tour_pg.spec.js",
              "line": 422,
              "column": 3
            },
            {
              "title": "findHeaderRowIndex returns 0 for well-formed files (no title rows)",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 1199,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:34.490Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-ed54ef0227153f9ae35e",
              "file": "tour_pg.spec.js",
              "line": 441,
              "column": 3
            },
            {
              "title": "source contains findHeaderRowIndex function",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 20,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:35.718Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-4f10869e881490f3f873",
              "file": "tour_pg.spec.js",
              "line": 456,
              "column": 3
            },
            {
              "title": "cache version is v22 or later",
              "ok": true,
              "tags": [],
              "tests": [
                {
                  "timeout": 30000,
                  "annotations": [],
                  "expectedStatus": "passed",
                  "projectId": "desktop-chrome",
                  "projectName": "desktop-chrome",
                  "results": [
                    {
                      "workerIndex": 5,
                      "parallelIndex": 0,
                      "status": "passed",
                      "duration": 20,
                      "errors": [],
                      "stdout": [],
                      "stderr": [],
                      "retry": 0,
                      "startTime": "2026-06-23T13:02:35.756Z",
                      "annotations": [],
                      "attachments": []
                    }
                  ],
                  "status": "expected"
                }
              ],
              "id": "c6527e00e898397fb4a5-f86894a2e417ced9e5ee",
              "file": "tour_pg.spec.js",
              "line": 464,
              "column": 3
            }
          ]
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-06-23T13:01:49.852Z",
    "duration": 46056.096000000005,
    "expected": 56,
    "skipped": 0,
    "unexpected": 2,
    "flaky": 0
  }
}
