mzidan000 commited on
Commit
5ad9595
·
verified ·
1 Parent(s): 50c1172

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. app.py +50 -3
  2. index.html +250 -94
  3. matchday/render.py +252 -127
app.py CHANGED
@@ -19,6 +19,7 @@ import json
19
  import logging
20
  import os
21
  import sys
 
22
  from datetime import date
23
  from pathlib import Path
24
 
@@ -75,6 +76,27 @@ def _ev(**payload) -> str:
75
  return json.dumps(payload, ensure_ascii=False)
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
79
  return TripRequest(
80
  origin_airport=a.origin_airport,
@@ -139,8 +161,15 @@ async def plan_trip(user_text: str) -> str:
139
  r1: dict = {"tool_calls": []}
140
  if agent is not None:
141
  yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
 
142
  try:
143
- r1 = await agent.run([{"role": "user", "content": user_text}])
 
 
 
 
 
 
144
  except Exception as exc:
145
  logger.warning("agent round 1 failed (%s).", exc)
146
  r1 = {"tool_calls": []}
@@ -178,8 +207,15 @@ async def plan_trip(user_text: str) -> str:
178
  type="commentary",
179
  text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
180
  )
 
181
  try:
182
- result = await build_trip_packages(trip)
 
 
 
 
 
 
183
  except Exception as exc:
184
  yield _ev(type="error", text=f"⚠️ {exc}")
185
  return
@@ -187,7 +223,18 @@ async def plan_trip(user_text: str) -> str:
187
  yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
188
  explanation = ""
189
  if agent is not None:
190
- explanation = await _agent_explain(agent, user_text, trip, result)
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  # Final: the full Layla-competitive render (status + cards + map + timeline).
193
  # leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
 
19
  import logging
20
  import os
21
  import sys
22
+ import time
23
  from datetime import date
24
  from pathlib import Path
25
 
 
76
  return json.dumps(payload, ensure_ascii=False)
77
 
78
 
79
+ async def _pulse(coro, holder, message, interval: int = 9):
80
+ """Run ``coro`` to completion, yielding a commentary heartbeat every
81
+ ``interval`` seconds (carrying elapsed seconds) so the SSE stream is never
82
+ silent during a long Modal cold-start or SerpApi phase. Stashes the result
83
+ in ``holder['result']``; re-raises if ``coro`` raised. Usage::
84
+
85
+ h = {}
86
+ async for beat in _pulse(coro, h, msg):
87
+ yield beat
88
+ value = h["result"]
89
+ """
90
+ task = asyncio.ensure_future(coro)
91
+ start = time.monotonic()
92
+ while True:
93
+ done, _ = await asyncio.wait({task}, timeout=interval)
94
+ if task in done:
95
+ holder["result"] = task.result()
96
+ return
97
+ yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
98
+
99
+
100
  def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
101
  return TripRequest(
102
  origin_airport=a.origin_airport,
 
161
  r1: dict = {"tool_calls": []}
162
  if agent is not None:
163
  yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
164
+ h1 = {}
165
  try:
166
+ async for beat in _pulse(
167
+ agent.run([{"role": "user", "content": user_text}]),
168
+ h1,
169
+ "🤖 Nemotron is choosing your best options…",
170
+ ):
171
+ yield beat
172
+ r1 = h1["result"]
173
  except Exception as exc:
174
  logger.warning("agent round 1 failed (%s).", exc)
175
  r1 = {"tool_calls": []}
 
207
  type="commentary",
208
  text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
209
  )
210
+ hb = {}
211
  try:
212
+ async for beat in _pulse(
213
+ build_trip_packages(trip),
214
+ hb,
215
+ "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather",
216
+ ):
217
+ yield beat
218
+ result = hb["result"]
219
  except Exception as exc:
220
  yield _ev(type="error", text=f"⚠️ {exc}")
221
  return
 
223
  yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
224
  explanation = ""
225
  if agent is not None:
226
+ he = {}
227
+ try:
228
+ async for beat in _pulse(
229
+ _agent_explain(agent, user_text, trip, result),
230
+ he,
231
+ "🗺️ Scoring 3 packages — Nemotron is writing your comparison…",
232
+ ):
233
+ yield beat
234
+ explanation = he["result"]
235
+ except Exception as exc:
236
+ logger.warning("explanation round failed (%s).", exc)
237
+ explanation = ""
238
 
239
  # Final: the full Layla-competitive render (status + cards + map + timeline).
240
  # leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
index.html CHANGED
@@ -8,116 +8,174 @@
8
  <!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
9
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
10
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
 
 
 
11
 
12
  <style>
13
  :root{
14
- --violet:#7c3aed; --violet-d:#6d28d9; --emerald:#10b981; --amber:#f59e0b;
15
- --ink:#111827; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
16
- --line:#e5e7eb; --panel:#f9fafb; --gray:#f3f4f6;
 
 
 
 
 
17
  }
18
  *{box-sizing:border-box;}
19
  html,body{margin:0;height:100%;}
20
  body{
21
  font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
22
- color:var(--ink); background:#fff; -webkit-font-smoothing:antialiased;
 
23
  }
24
- ::-webkit-scrollbar{width:9px;height:9px;}
25
- ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:6px;}
 
26
 
27
  /* ── Top bar ─────────────────────────────────────────── */
28
  .topbar{
29
- display:flex;align-items:center;gap:14px;padding:12px 20px;
30
- border-bottom:1px solid var(--line);
31
- background:linear-gradient(100deg,#0f172a,#1e3a8a);color:#fff;position:relative;z-index:5;
32
  }
33
- .topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:8px;}
34
- .topbar .tag{font-size:13px;opacity:.85;font-weight:500;}
 
 
35
  .topbar .sp{flex:1;}
36
- .topbar .pill{font-size:11px;font-weight:600;background:rgba(255,255,255,.14);
37
- padding:4px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.22);}
 
38
  .topbar .pill b{color:#c4b5fd;}
 
 
 
39
 
40
  /* ── 3-column layout ─────────────────────────────────── */
41
- .app{display:grid;grid-template-columns:minmax(320px,35%) minmax(0,45%) minmax(180px,20%);
42
- height:calc(100vh - 53px);}
43
  .col{display:flex;flex-direction:column;min-height:0;min-width:0;}
44
- .chat{border-right:1px solid var(--line);}
45
  .sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
46
 
47
  /* ── Chat column ─────────────────────────────────────── */
48
- #chat{flex:1;overflow-y:auto;padding:18px 18px 8px;display:flex;flex-direction:column;gap:14px;}
49
- .hero{background:var(--gray);border:1px solid var(--line);border-radius:14px;padding:16px;}
50
- .hero h2{margin:0 0 6px;font-size:15px;}
51
- .hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.5;}
52
- .msg{display:flex;gap:10px;max-width:92%;animation:fade .25s ease;}
 
 
53
  .msg.user{align-self:flex-end;flex-direction:row-reverse;}
54
- .a-avatar{width:30px;height:30px;border-radius:50%;background:#1e3a8a;color:#fff;
55
- display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 30px;}
56
- .a-body{background:var(--gray);border:1px solid var(--line);border-radius:14px;
57
- padding:11px 14px;min-width:0;}
58
- .msg.user .a-body{background:var(--violet);color:#fff;border-color:var(--violet-d);}
 
59
  .a-content{font-size:14px;line-height:1.5;}
60
  .a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
61
- font-weight:500;padding-top:2px;}
62
- .spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);}
63
  @keyframes spin{to{transform:rotate(360deg);}}
64
  @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
 
 
65
 
66
- .green{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534;border-radius:12px;
67
- padding:10px 13px;font-size:14px;font-weight:600;}
68
- .clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;}
69
- .err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;}
70
- .nemotron-note{font-size:11px;color:var(--mut2);margin-top:7px;font-weight:600;letter-spacing:.02em;}
71
- .msg.user .nemotron-note,.msg.user .a-beat{color:rgba(255,255,255,.92);}
 
 
 
72
 
73
  /* input row */
74
- .composer{border-top:1px solid var(--line);padding:12px 14px;background:#fff;}
75
- .composer textarea{width:100%;border:1px solid #d1d5db;border-radius:12px;padding:11px 13px;
76
- font:inherit;font-size:14px;resize:none;outline:none;min-height:48px;}
77
- .composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(124,58,237,.15);}
78
- .composer .row{display:flex;align-items:center;gap:8px;margin-top:8px;}
79
- .examples{display:flex;flex-wrap:wrap;gap:6px;padding:0 18px 10px;}
80
- .chip{font-size:12px;background:#fff;border:1px solid var(--line);border-radius:999px;
81
- padding:5px 11px;color:var(--ink2);cursor:pointer;transition:.15s;}
82
- .chip:hover{border-color:var(--violet);color:var(--violet);}
83
- button.primary{background:var(--violet);color:#fff;border:0;border-radius:10px;padding:9px 16px;
84
- font:inherit;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;}
85
- button.primary:hover{background:var(--violet-d);}
86
- button.primary:disabled{opacity:.55;cursor:not-allowed;}
87
- .hint{font-size:12px;color:var(--mut);}
 
 
 
88
 
89
  /* ── Result column ───────────────────────────────────── */
90
- .result{overflow-y:auto;}
91
- #result{padding:16px 18px 24px;}
92
  .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
93
- height:100%;text-align:center;color:var(--mut2);padding:30px;}
94
- .empty .big{font-size:46px;margin-bottom:10px;}
95
- .empty h3{margin:0 0 6px;font-size:16px;color:var(--ink2);}
96
- .empty p{margin:0;font-size:13px;max-width:320px;line-height:1.5;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  /* ── Sidebar ─────────────────────────────────────────── */
99
- .sidebar .sec{padding:16px 16px 14px;border-bottom:1px solid var(--line);}
100
- .sidebar h4{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);}
101
  .brain{display:flex;gap:10px;align-items:flex-start;}
102
- .brain .b{font-size:20px;}
103
- .brain .t{font-size:12.5px;line-height:1.45;color:var(--ink2);}
104
  .brain .t b{color:var(--violet);}
105
- .legend-row{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink2);margin:5px 0;}
106
- .dot{width:9px;height:9px;border-radius:50%;display:inline-block;}
107
- .dot.live{background:var(--emerald);}
108
  .dot.fallback{background:var(--amber);}
109
  .badges{display:flex;flex-wrap:wrap;gap:5px;}
110
- .badge{font-size:10.5px;font-weight:600;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
111
- border-radius:6px;padding:3px 7px;}
112
- .fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:4px 0;}
113
  .fact b{color:var(--ink);}
114
 
115
  /* full-screen map */
116
  #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
117
  height:100vh!important;width:100vw!important;border-radius:0!important;}
118
  #md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
119
- font-weight:700;border:0;border-radius:10px;padding:9px 15px;cursor:pointer;font-size:14px;
120
- box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;}
121
  #md-fs-close.show{display:block;}
122
 
123
  @media (max-width:980px){
@@ -130,10 +188,10 @@
130
  </head>
131
  <body>
132
  <header class="topbar">
133
- <div class="logo">⚽ MatchDay</div>
134
  <div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
135
  <div class="sp"></div>
136
- <span class="pill">Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
137
  </header>
138
 
139
  <div class="app">
@@ -157,7 +215,7 @@
157
  aria-label="Describe your trip"></textarea>
158
  <div class="row">
159
  <button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
160
- <span class="hint">Enter to send · Enter+Shift for newline</span>
161
  </div>
162
  </div>
163
  </section>
@@ -168,7 +226,7 @@
168
  <div class="empty">
169
  <div class="big">🏟️</div>
170
  <h3>Your trip packages will appear here</h3>
171
- <p>Once you send your request, Nemotron picks the tools and Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
172
  </div>
173
  </div>
174
  </section>
@@ -181,7 +239,7 @@
181
  <div class="b">🧠</div>
182
  <div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
183
  </div>
184
- <div class="brain" style="margin-top:10px;">
185
  <div class="b">🤲</div>
186
  <div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
187
  </div>
@@ -219,11 +277,25 @@ const EXAMPLES = [
219
  "Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
220
  "From Halifax, Canada vs Morocco June 18, couple, luxury",
221
  ];
222
- // Pre-fill the first example for instant demo.
223
  document.getElementById("prompt").value = EXAMPLES[0];
224
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  let running = false;
226
- let resultHandled = false; // the result event repeats on `complete` — render it once
 
 
 
227
  const chatEl = document.getElementById("chat");
228
  const resultEl = document.getElementById("result");
229
  const promptEl = document.getElementById("prompt");
@@ -245,16 +317,23 @@ function addAssistantBubble(){
245
  `<div class="a-avatar">🤖</div>
246
  <div class="a-body">
247
  <div class="a-content"></div>
248
- <div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span></div>
249
  </div>`;
250
  chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
251
  return m;
252
  }
253
- function setBeat(bubble, text){
254
- const beat = bubble.querySelector(".a-beat");
255
- if (!beat) return;
256
- beat.style.display = "flex";
257
- beat.querySelector(".beat-text").textContent = text;
 
 
 
 
 
 
 
258
  }
259
  function hideBeat(bubble){
260
  const beat = bubble.querySelector(".a-beat");
@@ -262,26 +341,72 @@ function hideBeat(bubble){
262
  }
263
  function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
264
 
265
- // ── Streaming via raw SSE (verified wire format) ───────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  function handleEvent(ev, bubble){
267
  if (!ev) return;
268
  if (ev.type === "commentary"){
269
- setBeat(bubble, ev.text);
 
270
  } else if (ev.type === "greenlight"){
 
271
  bubble.querySelector(".a-content").innerHTML =
272
  `<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
 
273
  } else if (ev.type === "clarify"){
274
  bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
275
- hideBeat(bubble);
276
  } else if (ev.type === "error"){
277
  bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
278
- hideBeat(bubble);
279
  } else if (ev.type === "result"){
280
- if (resultHandled) return; // `complete` re-emits the final yield — render once
281
  resultHandled = true;
282
- hideBeat(bubble);
 
283
  bubble.querySelector(".a-content").innerHTML =
284
- `<div class="green">✅ Built 3 ranked packages see them on the map</div>`;
285
  resultEl.innerHTML = ev.html;
286
  activateScripts(resultEl); // re-init the injected Leaflet map
287
  if (ev.explanation && ev.explanation.trim()){
@@ -290,7 +415,7 @@ function handleEvent(ev, bubble){
290
  const safe = esc(ev.explanation).replace(/\n/g,"<br>");
291
  nb.innerHTML =
292
  `<div class="a-avatar">🤖</div><div class="a-body">
293
- <div class="a-content"><b style="color:#1e3a8a">🤖 Nemotron compares your options</b><br>${safe}
294
  <div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
295
  </div></div>`;
296
  chatEl.appendChild(nb);
@@ -298,6 +423,12 @@ function handleEvent(ev, bubble){
298
  chatEl.scrollTop = chatEl.scrollHeight;
299
  }
300
  }
 
 
 
 
 
 
301
 
302
  function activateScripts(root){
303
  // innerHTML-inserted <script> tags don't auto-run — re-create them so the
@@ -309,38 +440,63 @@ function activateScripts(root){
309
  });
310
  }
311
 
 
 
 
 
 
 
 
 
 
 
312
  async function runTrip(forcedText){
313
  if (running) return;
314
  const text = (forcedText == null ? promptEl.value : forcedText).trim();
315
  if (!text) return;
316
- running = true; sendBtn.disabled = true;
 
317
  addUserBubble(text);
318
  const bubble = addAssistantBubble();
 
 
319
 
 
320
  try{
321
  const resp = await fetch("/gradio_api/call/plan_trip", {
322
  method: "POST", headers: {"Content-Type":"application/json"},
323
  body: JSON.stringify({ data: [text] }),
324
  });
 
325
  const j = await resp.json();
326
  const eventId = j && j.event_id;
327
- if (!eventId){ bubble.querySelector(".a-content").innerHTML = `<div class="err">No event id returned from the queue.</div>`; hideBeat(bubble); return; }
328
 
329
  const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
330
  es.addEventListener("generating", (e)=>{
331
- try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }catch(_){}
 
332
  });
333
  es.addEventListener("complete", (e)=>{
 
334
  try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
335
  es.close(); finish();
336
  });
337
- es.addEventListener("error", ()=>{ es.close(); finish(); });
 
 
 
 
 
 
 
 
338
  }catch(err){
339
- bubble.querySelector(".a-content").innerHTML = `<div class="err">⚠️ ${esc(err.message)}</div>`;
340
- hideBeat(bubble); finish();
341
  }
342
  }
343
- function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; }
344
 
345
  // ── Full-screen map (Layla frame 24) ───────────────────
346
  function matchdayFullscreen(on){
 
8
  <!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
9
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
10
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
14
 
15
  <style>
16
  :root{
17
+ --violet:#7c3aed; --violet-d:#6d28d9; --violet-l:#ede9fe;
18
+ --emerald:#10b981; --emerald-d:#047857;
19
+ --amber:#f59e0b;
20
+ --ink:#0f172a; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
21
+ --line:#e8eaee; --line2:#eef0f3; --panel:#f9fafb; --gray:#f3f4f6;
22
+ --bg:#f7f8fa; --surface:#ffffff;
23
+ --shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08);
24
+ --shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
25
  }
26
  *{box-sizing:border-box;}
27
  html,body{margin:0;height:100%;}
28
  body{
29
  font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
30
+ color:var(--ink); background:var(--bg); -webkit-font-smoothing:antialiased;
31
+ text-rendering:optimizeLegibility;
32
  }
33
+ ::-webkit-scrollbar{width:10px;height:10px;}
34
+ ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:8px;border:2px solid var(--bg);}
35
+ ::-webkit-scrollbar-thumb:hover{background:#b9c0c9;}
36
 
37
  /* ── Top bar ─────────────────────────────────────────── */
38
  .topbar{
39
+ display:flex;align-items:center;gap:14px;padding:13px 22px;
40
+ background:linear-gradient(100deg,#0f172a 0%,#1e293b 45%,#1e3a8a 100%);color:#fff;
41
+ position:relative;z-index:5;box-shadow:0 2px 14px rgba(15,23,42,.18);
42
  }
43
+ .topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.025em;display:flex;align-items:center;gap:9px;}
44
+ .topbar .logo .ball{filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
45
+ .topbar .tag{font-size:13px;opacity:.82;font-weight:500;}
46
+ .topbar .tag b{color:#c4b5fd;font-weight:600;}
47
  .topbar .sp{flex:1;}
48
+ .topbar .pill{font-size:11.5px;font-weight:600;background:rgba(255,255,255,.10);
49
+ padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.16);
50
+ display:inline-flex;align-items:center;gap:8px;}
51
  .topbar .pill b{color:#c4b5fd;}
52
+ .topbar .live-dot{width:7px;height:7px;border-radius:50%;background:#34d399;
53
+ box-shadow:0 0 0 0 rgba(52,211,153,.6);animation:pulse 2s infinite;}
54
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5);}70%{box-shadow:0 0 0 7px rgba(52,211,153,0);}100%{box-shadow:0 0 0 0 rgba(52,211,153,0);}}
55
 
56
  /* ── 3-column layout ─────────────────────────────────── */
57
+ .app{display:grid;grid-template-columns:minmax(330px,35%) minmax(0,45%) minmax(190px,21%);
58
+ height:calc(100vh - 57px);}
59
  .col{display:flex;flex-direction:column;min-height:0;min-width:0;}
60
+ .chat{border-right:1px solid var(--line);background:var(--bg);}
61
  .sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
62
 
63
  /* ── Chat column ─────────────────────────────────────── */
64
+ #chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;}
65
+ .hero{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:17px 18px;
66
+ box-shadow:var(--shadow-sm);}
67
+ .hero h2{margin:0 0 7px;font-size:15.5px;font-weight:800;letter-spacing:-.01em;}
68
+ .hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.55;}
69
+
70
+ .msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;}
71
  .msg.user{align-self:flex-end;flex-direction:row-reverse;}
72
+ .a-avatar{width:31px;height:31px;border-radius:50%;background:linear-gradient(135deg,#1e3a8a,#312e81);
73
+ color:#fff;display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 31px;
74
+ box-shadow:var(--shadow-sm);}
75
+ .a-body{background:var(--surface);border:1px solid var(--line2);border-radius:16px;
76
+ padding:11px 14px;min-width:0;box-shadow:var(--shadow-sm);}
77
+ .msg.user .a-body{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border-color:transparent;box-shadow:0 6px 16px -6px rgba(124,58,237,.5);}
78
  .a-content{font-size:14px;line-height:1.5;}
79
  .a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
80
+ font-weight:600;padding-top:3px;flex-wrap:wrap;}
81
+ .spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);font-size:14px;}
82
  @keyframes spin{to{transform:rotate(360deg);}}
83
  @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
84
+ .elapsed{font-size:12px;color:var(--violet);font-weight:700;font-variant-numeric:tabular-nums;
85
+ background:var(--violet-l);padding:1px 8px;border-radius:999px;}
86
 
87
+ .green{background:linear-gradient(135deg,#ecfdf5,#d1fae5);border:1px solid #a7f3d0;color:#065f46;
88
+ border-radius:12px;padding:10px 13px;font-size:14px;font-weight:700;}
89
+ .clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;line-height:1.5;}
90
+ .err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;line-height:1.5;}
91
+ .retry-btn{margin-top:9px;background:#fff;border:1px solid var(--violet);color:var(--violet);
92
+ border-radius:9px;padding:7px 14px;font:inherit;font-weight:700;font-size:13px;cursor:pointer;
93
+ display:inline-flex;align-items:center;gap:6px;transition:.15s;}
94
+ .retry-btn:hover{background:var(--violet-l);}
95
+ .nemotron-note{font-size:11px;color:var(--mut2);margin-top:8px;font-weight:600;letter-spacing:.02em;}
96
 
97
  /* input row */
98
+ .composer{border-top:1px solid var(--line);padding:12px 16px 14px;background:var(--surface);}
99
+ .composer textarea{width:100%;border:1px solid #d6d9df;border-radius:13px;padding:12px 14px;
100
+ font:inherit;font-size:14px;resize:none;outline:none;min-height:50px;background:var(--bg);
101
+ transition:.15s;line-height:1.45;}
102
+ .composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 4px rgba(124,58,237,.13);background:#fff;}
103
+ .composer .row{display:flex;align-items:center;gap:9px;margin-top:9px;}
104
+ .examples{display:flex;flex-wrap:wrap;gap:7px;padding:0 18px 8px;}
105
+ .chip{font-size:12px;background:var(--surface);border:1px solid var(--line2);border-radius:999px;
106
+ padding:6px 12px;color:var(--ink2);cursor:pointer;transition:.15s;box-shadow:var(--shadow-sm);}
107
+ .chip:hover{border-color:var(--violet);color:var(--violet);transform:translateY(-1px);}
108
+ button.primary{background:linear-gradient(135deg,var(--violet),var(--violet-d));color:#fff;border:0;
109
+ border-radius:11px;padding:10px 17px;font:inherit;font-weight:700;cursor:pointer;
110
+ display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px -6px rgba(124,58,237,.55);
111
+ transition:.15s;}
112
+ button.primary:hover{transform:translateY(-1px);box-shadow:0 10px 22px -6px rgba(124,58,237,.6);}
113
+ button.primary:disabled{opacity:.55;cursor:not-allowed;transform:none;}
114
+ .hint{font-size:11.5px;color:var(--mut2);}
115
 
116
  /* ── Result column ───────────────────────────────────── */
117
+ .result{overflow-y:auto;background:var(--bg);}
118
+ #result{padding:18px 20px 28px;}
119
  .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
120
+ height:100%;text-align:center;color:var(--mut2);padding:40px 30px;}
121
+ .empty .big{font-size:50px;margin-bottom:14px;filter:drop-shadow(0 6px 10px rgba(0,0,0,.08));}
122
+ .empty h3{margin:0 0 8px;font-size:17px;color:var(--ink2);font-weight:700;}
123
+ .empty p{margin:0;font-size:13.5px;max-width:340px;line-height:1.55;}
124
+
125
+ /* ── "Building your trip" skeleton state (premium wait UX) ── */
126
+ .building{padding:2px 0;}
127
+ .build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a);
128
+ color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md);
129
+ flex-wrap:wrap;}
130
+ .build-head .spin{color:#a5b4fc;}
131
+ .build-text{font-size:14px;font-weight:700;flex:1;min-width:120px;}
132
+ .build-head .elapsed{background:rgba(255,255,255,.14);color:#fff;border:1px solid rgba(255,255,255,.18);}
133
+ .cold-hint{font-size:12.5px;color:#c7d2fe;background:rgba(255,255,255,.08);border-radius:10px;
134
+ padding:8px 12px;margin-top:8px;line-height:1.45;width:100%;border:1px solid rgba(255,255,255,.10);}
135
+
136
+ @keyframes shimmer{100%{transform:translateX(100%);}}
137
+ .sk{position:relative;overflow:hidden;background:#e9ecf2;border-radius:13px;}
138
+ .sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
139
+ background:linear-gradient(90deg,transparent,rgba(255,255,255,.65),transparent);animation:shimmer 1.5s infinite;}
140
+ .sk-h{height:13px;margin-bottom:10px;}
141
+ .sk-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:15px;margin-bottom:22px;}
142
+ .sk-card{background:var(--surface);border:1px solid var(--line2);border-radius:18px;padding:18px;box-shadow:var(--shadow-sm);overflow:hidden;}
143
+ .sk-card .sk{margin-bottom:9px;}
144
+ .sk-card .sk.big{height:30px;width:60%;margin-bottom:14px;}
145
+ .sk-card .sk.line{height:12px;}
146
+ .sk-card .sk.line.w80{width:80%;}
147
+ .sk-card .sk.line.w60{width:60%;}
148
+ .sk-map{height:300px;border-radius:18px;margin-bottom:22px;}
149
+ .sk-tlrow{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:15px;
150
+ display:flex;gap:14px;align-items:center;margin-bottom:11px;box-shadow:var(--shadow-sm);overflow:hidden;}
151
+ .sk-tlrow .sq{width:32px;height:32px;border-radius:10px;flex:0 0 32px;}
152
+ .sk-tlrow .ln{flex:1;}
153
+ .sk-tlrow .ln .sk{height:12px;margin-bottom:7px;}
154
+ .sk-tlrow .ln .sk.w50{width:50%;}
155
 
156
  /* ── Sidebar ─────────────────────────────────────────── */
157
+ .sidebar .sec{padding:16px 16px 15px;border-bottom:1px solid var(--line);}
158
+ .sidebar h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);font-weight:800;}
159
  .brain{display:flex;gap:10px;align-items:flex-start;}
160
+ .brain .b{font-size:20px;line-height:1.2;}
161
+ .brain .t{font-size:12.5px;line-height:1.5;color:var(--ink2);}
162
  .brain .t b{color:var(--violet);}
163
+ .legend-row{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--ink2);margin:6px 0;}
164
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 9px;}
165
+ .dot.live{background:var(--emerald);box-shadow:0 0 0 2px rgba(16,185,129,.2);}
166
  .dot.fallback{background:var(--amber);}
167
  .badges{display:flex;flex-wrap:wrap;gap:5px;}
168
+ .badge{font-size:10.5px;font-weight:700;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
169
+ border-radius:7px;padding:3px 8px;}
170
+ .fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:5px 0;}
171
  .fact b{color:var(--ink);}
172
 
173
  /* full-screen map */
174
  #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
175
  height:100vh!important;width:100vw!important;border-radius:0!important;}
176
  #md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
177
+ font-weight:700;border:0;border-radius:11px;padding:9px 15px;cursor:pointer;font-size:14px;
178
+ box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;}
179
  #md-fs-close.show{display:block;}
180
 
181
  @media (max-width:980px){
 
188
  </head>
189
  <body>
190
  <header class="topbar">
191
+ <div class="logo"><span class="ball"></span> MatchDay</div>
192
  <div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
193
  <div class="sp"></div>
194
+ <span class="pill"><span class="live-dot"></span>Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
195
  </header>
196
 
197
  <div class="app">
 
215
  aria-label="Describe your trip"></textarea>
216
  <div class="row">
217
  <button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
218
+ <span class="hint">Enter to send · Shift+Enter for a newline</span>
219
  </div>
220
  </div>
221
  </section>
 
226
  <div class="empty">
227
  <div class="big">🏟️</div>
228
  <h3>Your trip packages will appear here</h3>
229
+ <p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
230
  </div>
231
  </div>
232
  </section>
 
239
  <div class="b">🧠</div>
240
  <div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
241
  </div>
242
+ <div class="brain" style="margin-top:11px;">
243
  <div class="b">🤲</div>
244
  <div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
245
  </div>
 
277
  "Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
278
  "From Halifax, Canada vs Morocco June 18, couple, luxury",
279
  ];
 
280
  document.getElementById("prompt").value = EXAMPLES[0];
281
 
282
+ // Phased progress messages (frontend-driven, carry perceived progress while the
283
+ // backend stream is silent during the Modal cold-start gap).
284
+ const PHASES = [
285
+ {t:0, m:"Reading your trip request…"},
286
+ {t:4, m:"🤖 Nemotron is choosing the right tools…"},
287
+ {t:14, m:"✈️ Scanning airlines for the cheapest route…"},
288
+ {t:30, m:"🏨 Finding hotels closest to BC Place…"},
289
+ {t:48, m:"🌤️ Checking the match-day forecast…"},
290
+ {t:68, m:"🗺️ Scoring 3 packages & ranking them…"},
291
+ {t:95, m:"✍️ Nemotron is writing your comparison…"},
292
+ ];
293
+
294
  let running = false;
295
+ let resultHandled = false; // the result event repeats on `complete` — render once
296
+ let elapsedTimer = null;
297
+ let lastRealAt = 0; // timestamp of last real stream event (ms)
298
+ let lastRealMsg = "";
299
  const chatEl = document.getElementById("chat");
300
  const resultEl = document.getElementById("result");
301
  const promptEl = document.getElementById("prompt");
 
317
  `<div class="a-avatar">🤖</div>
318
  <div class="a-body">
319
  <div class="a-content"></div>
320
+ <div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span><span class="elapsed" style="display:none">· 0:00</span></div>
321
  </div>`;
322
  chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
323
  return m;
324
  }
325
+ function setStatus(bubble, text){
326
+ const beat = bubble.querySelector(".beat-text");
327
+ if (beat) beat.textContent = text;
328
+ const bh = document.querySelector(".build-text");
329
+ if (bh) bh.textContent = text;
330
+ }
331
+ function setElapsed(bubble, secs){
332
+ const fmt = Math.floor(secs/60) + ":" + String(secs%60).padStart(2,"0");
333
+ const e = bubble.querySelector(".elapsed");
334
+ if (e){ e.textContent = "· " + fmt; e.style.display = "inline-block"; }
335
+ const be = document.querySelector(".build-head .elapsed");
336
+ if (be) be.textContent = "· " + fmt;
337
  }
338
  function hideBeat(bubble){
339
  const beat = bubble.querySelector(".a-beat");
 
341
  }
342
  function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
343
 
344
+ // ── Skeleton "building your trip" state ────────────────
345
+ function showSkeleton(bubble){
346
+ resultEl.innerHTML =
347
+ `<div class="building">
348
+ <div class="build-head">
349
+ <span class="spin">◐</span>
350
+ <span class="build-text">Reading your trip request…</span>
351
+ <span class="elapsed">· 0:00</span>
352
+ <div class="cold-hint" style="display:none"></div>
353
+ </div>
354
+ <div class="sk-cards">
355
+ ${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)}
356
+ </div>
357
+ <div class="sk sk-map"></div>
358
+ ${`<div class="sk-tlrow"><div class="sk sq"></div><div class="ln"><div class="sk w50"></div><div class="sk w80"></div></div></div>`.repeat(4)}
359
+ </div>`;
360
+ }
361
+
362
+ // ── Elapsed timer + phased progress + cold-start honesty ─
363
+ function startTimer(bubble){
364
+ stopTimer();
365
+ const t0 = performance.now();
366
+ elapsedTimer = setInterval(()=>{
367
+ const secs = Math.floor((performance.now()-t0)/1000);
368
+ setElapsed(bubble, secs);
369
+ // phased fallback if no real event recently
370
+ if (performance.now() - lastRealAt > 6000){
371
+ let ph = PHASES[0].m;
372
+ for (const p of PHASES){ if (secs >= p.t) ph = p.m; }
373
+ setStatus(bubble, ph);
374
+ }
375
+ // cold-start honesty after ~20s
376
+ const hint = document.querySelector(".cold-hint");
377
+ if (hint && secs >= 20 && hint.dataset.shown !== "1"){
378
+ hint.dataset.shown = "1";
379
+ hint.style.display = "block";
380
+ hint.textContent = "⏳ Warming up Nemotron-3-Nano-30B on Modal — the first query after idle can take ~1–2 min. It's working; the packages will stream in as soon as they're scored.";
381
+ }
382
+ }, 1000);
383
+ }
384
+ function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } }
385
+
386
+ // ── Streaming via raw SSE ───────────────────────────────
387
  function handleEvent(ev, bubble){
388
  if (!ev) return;
389
  if (ev.type === "commentary"){
390
+ lastRealAt = performance.now(); lastRealMsg = ev.text || "";
391
+ setStatus(bubble, lastRealMsg);
392
  } else if (ev.type === "greenlight"){
393
+ lastRealAt = performance.now();
394
  bubble.querySelector(".a-content").innerHTML =
395
  `<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
396
+ setStatus(bubble, "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather…");
397
  } else if (ev.type === "clarify"){
398
  bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
399
+ hideBeat(bubble); stopTimer(); clearSkeleton();
400
  } else if (ev.type === "error"){
401
  bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
402
+ hideBeat(bubble); stopTimer(); clearSkeleton();
403
  } else if (ev.type === "result"){
404
+ if (resultHandled) return; // `complete` re-emits the final yield — render once
405
  resultHandled = true;
406
+ lastRealAt = performance.now();
407
+ hideBeat(bubble); stopTimer();
408
  bubble.querySelector(".a-content").innerHTML =
409
+ `<div class="green">✅ Built 3 ranked packages see them on the map</div>`;
410
  resultEl.innerHTML = ev.html;
411
  activateScripts(resultEl); // re-init the injected Leaflet map
412
  if (ev.explanation && ev.explanation.trim()){
 
415
  const safe = esc(ev.explanation).replace(/\n/g,"<br>");
416
  nb.innerHTML =
417
  `<div class="a-avatar">🤖</div><div class="a-body">
418
+ <div class="a-content"><b style="color:#3730a3">🤖 Nemotron compares your options</b><br>${safe}
419
  <div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
420
  </div></div>`;
421
  chatEl.appendChild(nb);
 
423
  chatEl.scrollTop = chatEl.scrollHeight;
424
  }
425
  }
426
+ function clearSkeleton(){
427
+ const b = resultEl.querySelector(".building");
428
+ if (b && !resultHandled){
429
+ resultEl.innerHTML = `<div class="empty"><div class="big">🏟️</div><h3>Your trip packages will appear here</h3><p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots.</p></div>`;
430
+ }
431
+ }
432
 
433
  function activateScripts(root){
434
  // innerHTML-inserted <script> tags don't auto-run — re-create them so the
 
440
  });
441
  }
442
 
443
+ function showStreamError(bubble, text, retryText){
444
+ stopTimer();
445
+ hideBeat(bubble);
446
+ clearSkeleton();
447
+ const c = bubble.querySelector(".a-content");
448
+ c.innerHTML = `<div class="err">⚠️ ${esc(text||"Connection to the agent was interrupted.")}</div>
449
+ <button class="retry-btn" type="button">↻ Try again</button>`;
450
+ c.querySelector(".retry-btn").addEventListener("click", ()=>{ runTrip(retryText); });
451
+ }
452
+
453
  async function runTrip(forcedText){
454
  if (running) return;
455
  const text = (forcedText == null ? promptEl.value : forcedText).trim();
456
  if (!text) return;
457
+ running = true; sendBtn.disabled = true; resultHandled = false;
458
+ lastRealAt = 0; lastRealMsg = "";
459
  addUserBubble(text);
460
  const bubble = addAssistantBubble();
461
+ showSkeleton(bubble);
462
+ startTimer(bubble);
463
 
464
+ let completed = false;
465
  try{
466
  const resp = await fetch("/gradio_api/call/plan_trip", {
467
  method: "POST", headers: {"Content-Type":"application/json"},
468
  body: JSON.stringify({ data: [text] }),
469
  });
470
+ if (!resp.ok){ throw new Error("Server returned HTTP " + resp.status); }
471
  const j = await resp.json();
472
  const eventId = j && j.event_id;
473
+ if (!eventId){ throw new Error("No event id returned from the queue."); }
474
 
475
  const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
476
  es.addEventListener("generating", (e)=>{
477
+ try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }
478
+ catch(err){ console.warn("MatchDay: skipped a malformed stream chunk", err); }
479
  });
480
  es.addEventListener("complete", (e)=>{
481
+ completed = true;
482
  try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
483
  es.close(); finish();
484
  });
485
+ // gradio keepalive (ignored) — just mark the stream alive
486
+ es.addEventListener("heartbeat", ()=>{ lastRealAt = performance.now(); });
487
+ es.addEventListener("error", ()=>{
488
+ es.close();
489
+ if (completed) return; // clean end — EventSource fires error on close
490
+ if (resultHandled) return; // already rendered; nothing to do
491
+ showStreamError(bubble, "Connection to the agent dropped mid-stream.", text);
492
+ finish();
493
+ });
494
  }catch(err){
495
+ showStreamError(bubble, err && err.message ? err.message : "Request failed.", text);
496
+ finish();
497
  }
498
  }
499
+ function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; stopTimer(); }
500
 
501
  // ── Full-screen map (Layla frame 24) ───────────────────
502
  function matchdayFullscreen(on){
matchday/render.py CHANGED
@@ -1,10 +1,11 @@
1
- """HTML rendering for MatchDay — Layla-competitive cards + Leaflet map.
2
 
3
- Pure functions: a ``TripPackageResult`` -> HTML strings for ``gr.HTML`` panels.
4
  Every price / hotel / weather reading carries a provenance badge (I4) so judges
5
  can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
6
- "example data" (fallback). The look is custom (Off-Brand spirit), not the
7
- default Gradio chrome.
 
8
  """
9
  from __future__ import annotations
10
 
@@ -17,10 +18,21 @@ from matchday.trip_tool import TripPackageResult
17
  BC_PLACE_LAT = 49.2827
18
  BC_PLACE_LON = -123.1207
19
 
 
20
  _LABEL_COLOR = {
21
  "Cheapest": "#16a34a",
22
  "Safest Arrival": "#2563eb",
23
- "Closest to Stadium": "#9333ea",
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
  # WMO weather code -> emoji (subset; unknown -> 🌡️).
@@ -31,45 +43,115 @@ _WMO_ICON: dict[int, str] = {
31
  95: "⛈️", 96: "⛈️", 99: "⛈️",
32
  }
33
 
34
-
35
  _CSS = """
36
  <style>
37
- .md-wrap{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0f172a;}
38
- .md-status{padding:10px 14px;border-radius:10px;margin:8px 0;font-weight:600;}
39
- .md-status.complete{background:#dcfce7;color:#166534;}
40
- .md-status.partial{background:#fef9c3;color:#854d0e;}
41
- .md-status.failed{background:#fee2e2;color:#991b1b;}
42
- .md-cards{display:flex;gap:14px;flex-wrap:wrap;margin:10px 0;}
43
- .md-card{flex:1 1 300px;min-width:280px;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08);}
44
- .md-card-head{padding:12px 14px;color:#fff;}
45
- .md-card-head .lbl{font-size:13px;text-transform:uppercase;letter-spacing:.06em;opacity:.9;}
46
- .md-card-head .price{font-size:26px;font-weight:800;margin-top:2px;}
47
- .md-card-body{padding:12px 14px;display:grid;gap:8px;}
48
- .md-row{font-size:13px;line-height:1.35;}
49
- .md-row b{color:#334155;}
50
- .md-badge{display:inline-block;font-size:10px;font-weight:700;padding:1px 6px;border-radius:999px;text-transform:uppercase;letter-spacing:.04em;vertical-align:middle;margin-left:4px;}
51
- .md-live{background:#dcfce7;color:#166534;}
52
- .md-example{background:#fef3c7;color:#92400e;}
53
- .md-sub{color:#64748b;font-size:11px;}
54
- #matchday-map{height:460px;border-radius:12px;border:1px solid #e2e8f0;margin-top:10px;}
55
- .md-map-bar{display:flex;align-items:center;justify-content:space-between;margin-top:6px;}
56
- .md-fs-btn{font-size:12px;font-weight:600;color:#1e3a8a;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:5px 12px;cursor:pointer;}
57
- .md-fs-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:9999;display:none;}
58
- .md-fs-overlay.on{display:block;}
59
- .md-fs-map{position:absolute;inset:18px;border-radius:12px;overflow:hidden;}
60
- .md-fs-close{position:absolute;top:24px;right:28px;z-index:10000;background:#fff;color:#0f172a;font-weight:700;border:none;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:14px;}
61
- .md-progress{font-family:-apple-system,Roboto,sans-serif;padding:18px;background:linear-gradient(135deg,#0f172a,#1e3a8a);color:#fff;border-radius:14px;font-size:16px;font-weight:600;margin:10px 0;min-height:60px;display:flex;align-items:center;gap:12px;}
62
- .md-progress .spin{display:inline-block;animation:mdspin 1s linear infinite;}
63
- @keyframes mdspin{to{transform:rotate(360deg);}}
64
- .md-timeline{margin:14px 0;padding:0 4px;}
65
- .md-tl-row{display:flex;gap:14px;padding:10px 0;border-left:3px solid #e2e8f0;margin-left:14px;padding-left:18px;position:relative;}
66
- .md-tl-row.match{border-left-color:#16a34a;}
67
- .md-tl-dot{position:absolute;left:-15px;top:10px;width:28px;height:28px;border-radius:50%;background:#fff;border:2px solid #1e3a8a;display:flex;align-items:center;justify-content:center;font-size:15px;}
68
- .md-tl-row.match .md-tl-dot{border-color:#16a34a;background:#dcfce7;}
69
- .md-tl-title{font-weight:700;color:#0f172a;font-size:14px;}
70
- .md-tl-row.match .md-tl-title{color:#166534;}
71
- .md-tl-text{color:#475569;font-size:13px;margin-top:2px;line-height:1.4;}
72
- .md-tl-h{font-size:14px;font-weight:700;color:#1e3a8a;margin:14px 0 4px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </style>
74
  """
75
 
@@ -81,107 +163,145 @@ def _e(x: Any) -> str:
81
  def _prov_badge(source: str | None) -> str:
82
  s = (source or "").lower()
83
  if s == "fallback":
84
- return '<span class="md-badge md-example">example</span>'
85
  if s in ("serpapi", "openmeteo", "osm"):
86
- return '<span class="md-badge md-live">live</span>'
87
- return f'<span class="md-badge">{_e(source)}</span>'
 
 
 
 
 
 
88
 
89
 
90
- def _weather_line(pkg: ScoredPackage) -> str:
91
  if not pkg.weather:
92
- return '<div class="md-row">🌤️ <b>Weather:</b> forecast unavailable</div>'
 
 
 
93
  w = pkg.weather[0]
94
  icon = _WMO_ICON.get(w.weather_code, "🌡️")
95
  return (
96
- f'<div class="md-row">{icon} <b>Match-day weather:</b> {_e(w.date)} · '
97
- f'{w.temp_min_c:g}–{w.temp_max_c:g}°C · {w.precipitation_probability:g}% precip '
98
- f'{_prov_badge(w.source)}</div>'
99
  )
100
 
101
 
102
- def render_card(pkg: ScoredPackage) -> str:
103
- color = _LABEL_COLOR.get(pkg.label, "#475569")
 
 
104
  f = pkg.flight
105
  h = pkg.hotel
106
- hotel_html = (
107
- f'<div class="md-row">🏨 <b>{_e(h.name)}</b> · '
108
- f"{(h.total_price_cad is not None) and ('$'+format(round(h.total_price_cad), ',')) or 'price n/a'} total · "
109
- f'{(h.rating is not None) and (f"{h.rating:g}★") or ""} · '
110
- f'{(h.distance_to_stadium_km is not None) and (f"{h.distance_to_stadium_km:g} km to BC Place") or ""} '
111
- f"{_prov_badge(h.source)}</div>"
112
- ) if h else '<div class="md-row">🏨 <b>No hotel found</b> — flight-only package</div>'
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  buffer = pkg.arrival_buffer_hours
115
- buf_txt = f"+{buffer:g}h before kickoff" if buffer >= 0 else f"{buffer:g}h (arrives after kickoff!)"
 
 
 
 
 
116
 
117
  return f"""
118
- <div class="md-card">
119
- <div class="md-card-head" style="background:{color}">
120
- <div class="lbl">{_e(pkg.label)}</div>
121
- <div class="price">${pkg.total_cost_cad:,.0f} <span style="font-size:13px;font-weight:500;opacity:.85">CAD total</span></div>
122
- </div>
123
- <div class="md-card-body">
124
- <div class="md-row">✈️ <b>{_e(f.airline)} {_e(f.flight_number)}</b> · {_e(f.origin)}→{_e(f.destination)} ·
125
- lands {f.arrival_time.strftime('%H:%M')} {_prov_badge(f.source)}</div>
126
- {hotel_html}
127
- {_weather_line(pkg)}
128
- <div class="md-row">⏱️ <b>Arrival buffer:</b> {buf_txt}</div>
129
- <div class="md-row">🚶 <b>Hotel→stadium:</b> {pkg.hotel_to_stadium_min} min walk · {len(pkg.amenities)} nearby spots</div>
130
  </div>
131
- </div>
 
 
 
 
 
 
 
 
 
 
 
132
  """
133
 
134
 
135
  def render_cards(result: TripPackageResult) -> str:
136
  if not result.packages:
137
- return '<div class="md-row">No packages could be formed from the available data.</div>'
138
- cards = "".join(render_card(p) for p in result.packages)
139
- return f'{_CSS}<div class="md-wrap"><div class="md-cards">{cards}</div></div>'
140
 
141
 
142
  def render_status_bar(result: TripPackageResult) -> str:
143
  cls = result.status
144
- notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems go"
 
145
  return (
146
- f'{_CSS}<div class="md-wrap"><div class="md-status {cls}">'
147
- f"{result.status.upper()} · {len(result.packages)} package(s) · {notices}"
148
- f"</div></div>"
149
  )
150
 
151
 
152
  def _js_markers(result: TripPackageResult) -> str:
153
- """Build Leaflet JS adding stadium + hotel + POI markers + hotel→stadium lines."""
154
  lines: list[str] = []
155
- # BC Place stadium
156
  lines.append(
 
157
  f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
158
- f".addTo(map).bindPopup('<b>BC Place Stadium</b><br>Match venue');"
159
  )
160
- seen: set[tuple[float, float]] = set()
161
  for p in result.packages:
162
  h = p.hotel
163
  if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
164
  seen.add((h.latitude, h.longitude))
 
165
  name = escape(h.name)
166
  lines.append(
167
- f"L.marker([{h.latitude},{h.longitude}],{{icon:hotelIcon}})"
168
- f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{_e(p.label)}');"
 
169
  )
170
  lines.append(
171
  f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
172
- f"{{color:'#2563eb',dashArray:'5,7',weight:2}}).addTo(map);"
173
  )
174
- # POIs (from first package that has them)
175
  for p in result.packages:
176
- for a in (p.amenities or [])[:15]:
177
  if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
178
  seen.add((a.latitude, a.longitude))
179
  lines.append(
180
- f"L.marker([{a.latitude},{a.longitude}],{{icon:poiIcon}})"
 
181
  f".addTo(map).bindPopup('{escape(a.category)} · {escape(a.name)}');"
182
  )
183
  if p.amenities:
184
  break
 
 
 
185
  return "\n".join(lines)
186
 
187
 
@@ -195,23 +315,28 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
195
  return f"""
196
  {_CSS}
197
  <div class="md-wrap">
198
- {_cdn} <div class="md-map-bar">
199
- <span class="md-sub">🏟️ BC Place · 🏨 hotels · 📍 nearby spots — drag &amp; zoom to explore</span>
 
200
  <button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&amp;&amp;matchdayFullscreen()">⛶ Full screen</button>
 
 
 
 
 
 
 
 
201
  </div>
202
- <div id="matchday-map"></div>
203
  <script>
204
  (function(){{
205
  var el=document.getElementById('matchday-map');
206
  if(!el||window.L===undefined){{return;}}
207
  if(el._lmap){{el._lmap.remove();}}
208
- var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
209
- el._lmap=map;
210
- window._matchdayMap=map;
211
- L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
212
- var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
213
- var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
214
- var poiIcon=L.divIcon({{html:'📍',iconSize:[16,16],iconAnchor:[8,8]}});
215
  {_js_markers(result)}
216
  }})();
217
  </script>
@@ -220,11 +345,7 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
220
 
221
 
222
  def render_timeline(trip, result: TripPackageResult) -> str:
223
- """Day-by-day vertical itinerary (A2): arrival → match day → explore.
224
-
225
- Uses the top package's flight/hotel to anchor Day 1; the match day is the
226
- highlighted centerpiece.
227
- """
228
  from datetime import timedelta
229
 
230
  top = result.packages[0] if result.packages else None
@@ -235,46 +356,50 @@ def render_timeline(trip, result: TripPackageResult) -> str:
235
  idx += 1
236
  if d == trip.check_in and top:
237
  hotel = top.hotel.name if top.hotel else "your hotel"
238
- icon, title, body, cls = (
239
- "✈️", f"Day {idx} · {d:%a %b %d}",
240
- f"Fly <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
241
- f"({_e(top.flight.origin)}→YVR, lands {top.flight.arrival_time:%H:%M}); "
242
- f"check in to {_e(hotel)}.",
243
  "",
244
  )
245
  elif d == trip.match_date:
246
- icon, title, body, cls = (
247
- "🏟️", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
248
- f"<b>{_e(trip.match_name)}</b> at BC Place Stadium, 7:00 PM PT kickoff. "
249
- "Soak up the fan zone, then head in early.",
250
- "match",
251
  )
252
  elif d < trip.match_date:
253
- icon, title, body, cls = (
254
- "🏙️", f"Day {idx} · {d:%a %b %d}",
255
- "Arrive & settle — Stanley Park, Granville Island, the seawall.",
256
  "",
257
  )
258
  else:
259
- icon, title, body, cls = (
260
- "🗺️", f"Day {idx} · {d:%a %b %d}",
261
- "Day after the match: brunch, last sights, fly home.",
262
  "",
263
  )
264
  items.append(
265
- f'<div class="md-tl-row {cls}"><div class="md-tl-dot">{icon}</div>'
266
- f'<div><div class="md-tl-title">{title}</div>'
267
- f'<div class="md-tl-text">{body}</div></div></div>'
 
268
  )
269
  d += timedelta(days=1)
270
  return (
271
- f'{_CSS}<div class="md-wrap"><div class="md-tl-h">📅 Day-by-day itinerary</div>'
272
- f'<div class="md-timeline">{"".join(items)}</div></div>'
273
  )
274
 
275
 
276
  def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
277
- out = render_status_bar(result) + render_cards(result) + render_map(result, leaflet_preloaded=leaflet_preloaded)
 
 
 
 
278
  if trip is not None:
279
  out += render_timeline(trip, result)
280
  return out
 
1
+ """HTML rendering for MatchDay — premium, Layla-competitive cards + Leaflet map.
2
 
3
+ Pure functions: a ``TripPackageResult`` -> HTML strings for the result panel.
4
  Every price / hotel / weather reading carries a provenance badge (I4) so judges
5
  can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
6
+ "example" (fallback). The look is a refined travel-product aesthetic
7
+ (Off-Brand spirit) — white surfaces, layered soft shadows, restrained accent
8
+ pops, generous whitespace, a flat CARTO map — not stock Gradio chrome.
9
  """
10
  from __future__ import annotations
11
 
 
18
  BC_PLACE_LAT = 49.2827
19
  BC_PLACE_LON = -123.1207
20
 
21
+ # Per-label accent + matching soft tint (avoids color-mix for max compatibility).
22
  _LABEL_COLOR = {
23
  "Cheapest": "#16a34a",
24
  "Safest Arrival": "#2563eb",
25
+ "Closest to Stadium": "#7c3aed",
26
+ }
27
+ _LABEL_TINT = {
28
+ "Cheapest": "#dcfce7",
29
+ "Safest Arrival": "#dbeafe",
30
+ "Closest to Stadium": "#ede9fe",
31
+ }
32
+ _LABEL_ICON = {
33
+ "Cheapest": "💰",
34
+ "Safest Arrival": "🛬",
35
+ "Closest to Stadium": "📍",
36
  }
37
 
38
  # WMO weather code -> emoji (subset; unknown -> 🌡️).
 
43
  95: "⛈️", 96: "⛈️", 99: "⛈️",
44
  }
45
 
 
46
  _CSS = """
47
  <style>
48
+ .md-wrap{font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827;-webkit-font-smoothing:antialiased;}
49
+
50
+ /* ── Provenance pills (the "no hallucinated prices" differentiator) ── */
51
+ .md-pv{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:1px 7px 1px 5px;
52
+ border-radius:999px;letter-spacing:.03em;text-transform:uppercase;vertical-align:middle;white-space:nowrap;}
53
+ .md-pv::before{content:"";width:6px;height:6px;border-radius:50%;display:inline-block;}
54
+ .md-pv-live{background:#ecfdf5;color:#047857;}
55
+ .md-pv-live::before{background:#10b981;box-shadow:0 0 0 2px rgba(16,185,129,.2);}
56
+ .md-pv-ex{background:#fffbeb;color:#b45309;}
57
+ .md-pv-ex::before{background:#f59e0b;}
58
+ .md-pv-other{background:#f3f4f6;color:#4b5563;}
59
+ .md-pv-other::before{background:#9ca3af;}
60
+
61
+ /* ── Section header ── */
62
+ .md-sec-h{display:flex;align-items:center;gap:12px;margin:26px 2px 14px;}
63
+ .md-sec-h .md-sec-t{font-size:12.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:#6b7280;white-space:nowrap;}
64
+ .md-sec-h .md-sec-line{flex:1;height:1px;background:linear-gradient(90deg,#eef0f3,transparent);}
65
+
66
+ /* ── Status pill ── */
67
+ .md-status{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;padding:7px 14px;
68
+ border-radius:999px;margin:2px 0 4px;}
69
+ .md-status.complete{background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;}
70
+ .md-status.partial{background:#fffbeb;color:#b45309;border:1px solid #fde68a;}
71
+ .md-status.failed{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;}
72
+ .md-status .md-dot{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px rgba(16,185,129,.15);}
73
+ .md-status .md-status-sub{font-weight:500;opacity:.85;margin-left:2px;}
74
+
75
+ /* ── Package cards ── */
76
+ .md-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:16px;margin:6px 0 2px;}
77
+ .md-card{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
78
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
79
+ display:flex;flex-direction:column;transition:transform .18s ease,box-shadow .18s ease;}
80
+ .md-card:hover{transform:translateY(-2px);box-shadow:0 2px 6px rgba(17,24,39,.06),0 16px 34px -14px rgba(17,24,39,.22);}
81
+ .md-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--accent,#7c3aed);}
82
+ .md-card.is-top{border-color:#ddd6fe;box-shadow:0 2px 6px rgba(124,58,237,.10),0 20px 42px -14px rgba(124,58,237,.32);}
83
+ .md-topflag{position:absolute;top:15px;right:15px;background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;
84
+ font-size:10px;font-weight:800;letter-spacing:.04em;padding:4px 10px 4px 8px;border-radius:999px;
85
+ box-shadow:0 6px 14px -4px rgba(124,58,237,.5);display:inline-flex;align-items:center;gap:4px;}
86
+
87
+ .md-card-top{display:flex;align-items:center;gap:9px;padding:17px 18px 0 22px;}
88
+ .md-chip{display:inline-flex;align-items:center;gap:6px;background:var(--tint,#ede9fe);color:var(--accent,#7c3aed);
89
+ font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;padding:5px 11px;border-radius:999px;}
90
+ .md-chip .md-chip-ico{font-size:13px;}
91
+
92
+ .md-price{display:flex;align-items:baseline;gap:5px;margin:9px 0 0 22px;}
93
+ .md-cur{font-size:15px;font-weight:700;color:#9ca3af;}
94
+ .md-amount{font-size:33px;font-weight:800;letter-spacing:-.025em;color:#111827;line-height:1;}
95
+ .md-price-sub{color:#9ca3af;font-size:11.5px;font-weight:600;margin:4px 0 0 22px;}
96
+
97
+ .md-facts{list-style:none;margin:15px 0 0;padding:0 22px 18px;display:grid;gap:12px;}
98
+ .md-facts li{display:flex;gap:12px;align-items:flex-start;font-size:13.5px;line-height:1.4;}
99
+ .md-facts .md-fico{font-size:16px;flex:0 0 22px;text-align:center;margin-top:1px;line-height:1;}
100
+ .md-facts .md-fbody{min-width:0;}
101
+ .md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
102
+ .md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12.5px;margin-top:2px;}
103
+
104
+ /* ── Map ── */
105
+ .md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
106
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);}
107
+ #matchday-map{height:430px;background:#eef0f3;}
108
+ .md-map-tag{position:absolute;top:12px;left:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:999px;
109
+ padding:6px 13px;font-size:12px;font-weight:700;color:#374151;box-shadow:0 2px 10px rgba(17,24,39,.10);
110
+ display:inline-flex;align-items:center;gap:8px;}
111
+ .md-mdot{width:9px;height:9px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px rgba(17,24,39,.18);}
112
+ .md-fs-btn{position:absolute;top:12px;right:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:10px;
113
+ padding:7px 12px;font:inherit;font-size:12px;font-weight:700;color:#374151;cursor:pointer;
114
+ box-shadow:0 2px 10px rgba(17,24,39,.10);display:inline-flex;align-items:center;gap:5px;}
115
+ .md-fs-btn:hover{background:#f7f8fa;}
116
+ .md-legend{display:flex;gap:18px;flex-wrap:wrap;margin:13px 3px 0;font-size:12px;color:#6b7280;}
117
+ .md-legend span{display:inline-flex;align-items:center;gap:7px;}
118
+
119
+ /* leaflet marker polish */
120
+ .md-pin{width:18px;height:18px;border-radius:50%;background:var(--c,#7c3aed);border:3px solid #fff;
121
+ box-shadow:0 2px 6px rgba(17,24,39,.35);}
122
+ .md-pin-stadium{width:30px;height:30px;background:#111827;border:4px solid #fff;display:flex;align-items:center;
123
+ justify-content:center;font-size:15px;}
124
+ .leaflet-popup-content-wrapper{border-radius:12px;box-shadow:0 8px 24px -8px rgba(17,24,39,.25);}
125
+ .leaflet-popup-content{margin:10px 13px;font-size:13px;font-family:Inter,sans-serif;}
126
+ .leaflet-control-zoom a{border-radius:8px!important;}
127
+
128
+ /* full-screen map (toggled from index.html) */
129
+ #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;height:100vh!important;
130
+ width:100vw!important;border-radius:0!important;}
131
+
132
+ /* ── Day-by-day timeline ── */
133
+ .md-tl{display:grid;gap:11px;margin-top:6px;}
134
+ .md-day{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:16px;padding:14px 16px 14px 60px;
135
+ box-shadow:0 1px 2px rgba(17,24,39,.04);transition:box-shadow .18s ease;}
136
+ .md-day:hover{box-shadow:0 4px 14px -8px rgba(17,24,39,.18);}
137
+ .md-day-ico{position:absolute;left:14px;top:14px;width:32px;height:32px;border-radius:10px;background:#f3f4f6;
138
+ display:flex;align-items:center;justify-content:center;font-size:16px;}
139
+ .md-day-lbl{font-size:10.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#9ca3af;}
140
+ .md-day-title{font-size:15px;font-weight:700;color:#111827;margin:2px 0 4px;letter-spacing:-.01em;}
141
+ .md-day-body{font-size:13px;color:#6b7280;line-height:1.5;}
142
+ .md-day-body b{color:#374151;}
143
+ .md-day.is-match{border-color:#bbf7d0;background:linear-gradient(180deg,#f0fdf4,#fff 60%);
144
+ box-shadow:0 2px 8px rgba(16,185,129,.12);}
145
+ .md-day.is-match .md-day-ico{background:#dcfce7;}
146
+ .md-day.is-match .md-day-lbl{color:#047857;}
147
+ .md-day.is-match .md-day-title{color:#065f46;}
148
+
149
+ /* ── Explanation (Nemotron's comparison) ── */
150
+ .md-explain{background:linear-gradient(135deg,#eef2ff,#f5f3ff);border:1px solid #ddd6fe;border-radius:16px;
151
+ padding:16px 18px;margin-top:6px;color:#3730a3;font-size:13.5px;line-height:1.6;}
152
+ .md-explain b{color:#4c1d95;}
153
+ .md-explain-h{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;letter-spacing:.04em;
154
+ text-transform:uppercase;color:#6d28d9;margin-bottom:8px;}
155
  </style>
156
  """
157
 
 
163
  def _prov_badge(source: str | None) -> str:
164
  s = (source or "").lower()
165
  if s == "fallback":
166
+ return '<span class="md-pv md-pv-ex">example</span>'
167
  if s in ("serpapi", "openmeteo", "osm"):
168
+ return '<span class="md-pv md-pv-live">live</span>'
169
+ if not s:
170
+ return ""
171
+ return f'<span class="md-pv md-pv-other">{_e(source)}</span>'
172
+
173
+
174
+ def _section(title: str) -> str:
175
+ return f'<div class="md-sec-h"><span class="md-sec-t">{_e(title)}</span><span class="md-sec-line"></span></div>'
176
 
177
 
178
+ def _weather_fact(pkg: ScoredPackage) -> str:
179
  if not pkg.weather:
180
+ return (
181
+ '<li><span class="md-fico">🌤️</span><span class="md-fbody"><b>Forecast unavailable</b>'
182
+ '<span class="md-fdet">match-day weather could not be loaded</span></span></li>'
183
+ )
184
  w = pkg.weather[0]
185
  icon = _WMO_ICON.get(w.weather_code, "🌡️")
186
  return (
187
+ f'<li><span class="md-fico">{icon}</span><span class="md-fbody"><b>{w.temp_min_c:g}–{w.temp_max_c:g}°C</b>'
188
+ f'<span class="md-fdet">{_e(w.date)} · match day · {w.precipitation_probability:g}% rain '
189
+ f'{_prov_badge(w.source)}</span></span></li>'
190
  )
191
 
192
 
193
+ def render_card(pkg: ScoredPackage, is_top: bool = False) -> str:
194
+ accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
195
+ tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
196
+ icon = _LABEL_ICON.get(pkg.label, "✅")
197
  f = pkg.flight
198
  h = pkg.hotel
 
 
 
 
 
 
 
199
 
200
+ # Hotel fact
201
+ if h:
202
+ bits = []
203
+ if h.total_price_cad is not None:
204
+ bits.append(f"${format(round(h.total_price_cad), ',')} total")
205
+ if h.rating is not None:
206
+ bits.append(f"{h.rating:g}★")
207
+ if h.distance_to_stadium_km is not None:
208
+ bits.append(f"{h.distance_to_stadium_km:g} km to BC Place")
209
+ hotel_fact = (
210
+ f'<li><span class="md-fico">🏨</span><span class="md-fbody"><b>{_e(h.name)}</b>'
211
+ f'<span class="md-fdet">{" · ".join(bits)} {_prov_badge(h.source)}</span></span></li>'
212
+ )
213
+ else:
214
+ hotel_fact = (
215
+ '<li><span class="md-fico">🏨</span><span class="md-fbody"><b>Flight-only package</b>'
216
+ '<span class="md-fdet">no hotel bundled for this option</span></span></li>'
217
+ )
218
+
219
+ # Arrival buffer
220
  buffer = pkg.arrival_buffer_hours
221
+ if buffer >= 0:
222
+ buf_b, buf_d = f"+{buffer:g}h before kickoff", "arrival buffer"
223
+ else:
224
+ buf_b, buf_d = f"{buffer:g}h — lands AFTER kickoff", "⚠️ risky arrival"
225
+
226
+ topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
227
 
228
  return f"""
229
+ <article class="md-card{' is-top' if is_top else ''}" style="--accent:{accent};--tint:{tint}">
230
+ {topflag}
231
+ <div class="md-card-top">
232
+ <span class="md-chip"><span class="md-chip-ico">{icon}</span>{_e(pkg.label)}</span>
 
 
 
 
 
 
 
 
233
  </div>
234
+ <div class="md-price"><span class="md-cur">CA$</span><span class="md-amount">{pkg.total_cost_cad:,.0f}</span></div>
235
+ <div class="md-price-sub">CAD · total for the trip</div>
236
+ <ul class="md-facts">
237
+ <li><span class="md-fico">✈️</span><span class="md-fbody"><b>{_e(f.airline)} {_e(f.flight_number)}</b>
238
+ <span class="md-fdet">{_e(f.origin)} → {_e(f.destination)} · lands {f.arrival_time:%H:%M} {_prov_badge(f.source)}</span></span></li>
239
+ {hotel_fact}
240
+ {_weather_fact(pkg)}
241
+ <li><span class="md-fico">⏱️</span><span class="md-fbody"><b>{buf_b}</b><span class="md-fdet">{buf_d}</span></span></li>
242
+ <li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
243
+ <span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
244
+ </ul>
245
+ </article>
246
  """
247
 
248
 
249
  def render_cards(result: TripPackageResult) -> str:
250
  if not result.packages:
251
+ return '<div class="md-row" style="padding:14px;color:#6b7280">No packages could be formed from the available data.</div>'
252
+ cards = "".join(render_card(p, is_top=(i == 0)) for i, p in enumerate(result.packages))
253
+ return f'{_CSS}<div class="md-wrap">{_section("Your 3 ranked packages")}<div class="md-cards">{cards}</div></div>'
254
 
255
 
256
  def render_status_bar(result: TripPackageResult) -> str:
257
  cls = result.status
258
+ notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems live"
259
+ label = {"complete": "Live data", "partial": "Partial data", "failed": "Degraded"}.get(cls, cls)
260
  return (
261
+ f'{_CSS}<div class="md-wrap"><span class="md-status {cls}"><span class="md-dot"></span>{_e(label)}'
262
+ f'<span class="md-status-sub">· {len(result.packages)} packages · {_e(notices)}</span></span></div>'
 
263
  )
264
 
265
 
266
  def _js_markers(result: TripPackageResult) -> str:
267
+ """Build Leaflet JS: stadium + hotel + POI markers + hotel→stadium lines."""
268
  lines: list[str] = []
 
269
  lines.append(
270
+ f"var bb=[[{BC_PLACE_LAT},{BC_PLACE_LON}]];"
271
  f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
272
+ f".addTo(map).bindPopup('<b>🏟️ BC Place Stadium</b><br>Match venue · 7:00 PM PT');"
273
  )
274
+ seen: set[tuple[float, float]] = {(BC_PLACE_LAT, BC_PLACE_LON)}
275
  for p in result.packages:
276
  h = p.hotel
277
  if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
278
  seen.add((h.latitude, h.longitude))
279
+ accent = _LABEL_COLOR.get(p.label, "#7c3aed")
280
  name = escape(h.name)
281
  lines.append(
282
+ f"bb.push([{h.latitude},{h.longitude}]);"
283
+ f"L.marker([{h.latitude},{h.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" style=\\\"--c:{accent}\\\"></div>',iconSize:[18,18],iconAnchor:[9,9],popupAnchor:[0,-8]}})}})"
284
+ f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{escape(p.label)}');"
285
  )
286
  lines.append(
287
  f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
288
+ f"{{color:'{accent}',weight:2,opacity:.7,dashArray:'1 7',lineCap:'round'}}).addTo(map);"
289
  )
290
+ # POIs (first package that has them)
291
  for p in result.packages:
292
+ for a in (p.amenities or [])[:18]:
293
  if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
294
  seen.add((a.latitude, a.longitude))
295
  lines.append(
296
+ f"bb.push([{a.latitude},{a.longitude}]);"
297
+ f"L.marker([{a.latitude},{a.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" style=\\\"--c:#9ca3af;width:10px;height:10px;border-width:2px\\\"></div>',iconSize:[10,10],iconAnchor:[5,5],popupAnchor:[0,-6]}})}})"
298
  f".addTo(map).bindPopup('{escape(a.category)} · {escape(a.name)}');"
299
  )
300
  if p.amenities:
301
  break
302
+ lines.append(
303
+ "try{map.fitBounds(bb,{padding:[34,34],maxZoom:16});}catch(e){}"
304
+ )
305
  return "\n".join(lines)
306
 
307
 
 
315
  return f"""
316
  {_CSS}
317
  <div class="md-wrap">
318
+ {_cdn}{_section("On the map")}
319
+ <div class="md-map-wrap">
320
+ <span class="md-map-tag"><span class="md-mdot" style="background:#111827"></span>BC Place</span>
321
  <button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&amp;&amp;matchdayFullscreen()">⛶ Full screen</button>
322
+ <div id="matchday-map"></div>
323
+ </div>
324
+ <div class="md-legend">
325
+ <span><span class="md-mdot" style="background:#111827"></span>BC Place Stadium</span>
326
+ <span><span class="md-mdot" style="background:#16a34a"></span>Cheapest hotel</span>
327
+ <span><span class="md-mdot" style="background:#2563eb"></span>Safest-arrival hotel</span>
328
+ <span><span class="md-mdot" style="background:#7c3aed"></span>Closest hotel</span>
329
+ <span><span class="md-mdot" style="background:#9ca3af"></span>Nearby spots</span>
330
  </div>
 
331
  <script>
332
  (function(){{
333
  var el=document.getElementById('matchday-map');
334
  if(!el||window.L===undefined){{return;}}
335
  if(el._lmap){{el._lmap.remove();}}
336
+ var map=L.map('matchday-map',{{scrollWheelZoom:true,zoomControl:true}}).setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
337
+ el._lmap=map; window._matchdayMap=map;
338
+ L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}.png',{{subdomains:'abcd',maxZoom:20,attribution:'&amp;copy; OpenStreetMap &amp;copy; CARTO'}}).addTo(map);
339
+ var stadiumIcon=L.divIcon({{className:'',html:'<div class="md-pin md-pin-stadium">🏟️</div>',iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-12]}});
 
 
 
340
  {_js_markers(result)}
341
  }})();
342
  </script>
 
345
 
346
 
347
  def render_timeline(trip, result: TripPackageResult) -> str:
348
+ """Day-by-day itinerary (A2): arrival → match day → explore."""
 
 
 
 
349
  from datetime import timedelta
350
 
351
  top = result.packages[0] if result.packages else None
 
356
  idx += 1
357
  if d == trip.check_in and top:
358
  hotel = top.hotel.name if top.hotel else "your hotel"
359
+ icon, lbl, title, body, cls = (
360
+ "✈️", "Arrival", f"Day {idx} · {d:%a %b %d}",
361
+ f"Land via <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
362
+ f"({_e(top.flight.origin)} YVR) and check in to <b>{_e(hotel)}</b>. Settle in, grab dinner downtown.",
 
363
  "",
364
  )
365
  elif d == trip.match_date:
366
+ icon, lbl, title, body, cls = (
367
+ "🏟️", "Match day", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
368
+ f"<b>{_e(trip.match_name)}</b> at BC Place, 7:00 PM PT. Hit the fan zone first, "
369
+ "then walk from your hotel it's minutes away.",
370
+ "is-match",
371
  )
372
  elif d < trip.match_date:
373
+ icon, lbl, title, body, cls = (
374
+ "🏙️", "Explore", f"Day {idx} · {d:%a %b %d}",
375
+ "Stanley Park seawall, Granville Island, Gastown — Vancouver is built for walking.",
376
  "",
377
  )
378
  else:
379
+ icon, lbl, title, body, cls = (
380
+ "🗺️", "Heading home", f"Day {idx} · {d:%a %b %d}",
381
+ "Brunch and last sights, then back to YVR. Safe flight home.",
382
  "",
383
  )
384
  items.append(
385
+ f'<div class="md-day {cls}"><div class="md-day-ico">{icon}</div>'
386
+ f'<div class="md-day-lbl">{lbl}</div>'
387
+ f'<div class="md-day-title">{title}</div>'
388
+ f'<div class="md-day-body">{body}</div></div>'
389
  )
390
  d += timedelta(days=1)
391
  return (
392
+ f'{_CSS}<div class="md-wrap">{_section("Day-by-day itinerary")}'
393
+ f'<div class="md-tl">{"".join(items)}</div></div>'
394
  )
395
 
396
 
397
  def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
398
+ out = (
399
+ render_status_bar(result)
400
+ + render_cards(result)
401
+ + render_map(result, leaflet_preloaded=leaflet_preloaded)
402
+ )
403
  if trip is not None:
404
  out += render_timeline(trip, result)
405
  return out