← Files Vacation Planner ProARCHIVED FILE
skills/trip-dashboard/assets/trip-dashboard-template.html
29.4 KB · Oct 4, 2026 · 12:34 UTC
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Trip decision workspace</title>
<style>
:root{--ink:#122d32;--muted:#587075;--teal:#0c6662;--teal2:#164d4d;--sea:#dff2ec;--mist:#f2f7f5;--paper:#fff;--line:#cadbd6;--gold:#d99b2b;--warn:#8f4a25;--shadow:0 15px 38px rgba(19,57,57,.12);--r:16px}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:82px}body{margin:0;background:#edf4f1;color:var(--ink);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}button,input{font:inherit}button,a{touch-action:manipulation}a{color:var(--teal);text-underline-offset:3px}:focus-visible{outline:3px solid var(--gold);outline-offset:3px}.wrap{max-width:1520px;margin:auto;padding:22px}.mast{position:relative;overflow:hidden;border-radius:22px;background:linear-gradient(125deg,#092f31,#0c6662 62%,#348778);color:white;padding:34px;box-shadow:var(--shadow)}.mast:after{content:"";position:absolute;right:-90px;top:-130px;width:410px;height:410px;border:1px solid rgba(255,255,255,.16);border-radius:50%;box-shadow:0 0 0 55px rgba(255,255,255,.035),0 0 0 115px rgba(255,255,255,.025)}.eyebrow{margin:0 0 8px;text-transform:uppercase;letter-spacing:.13em;font-size:12px;font-weight:800;color:#a9e4d4}.mast h1{position:relative;max-width:900px;margin:0;font:700 clamp(30px,5vw,58px)/1.02 Georgia,serif;letter-spacing:-.035em}.subtitle{position:relative;max-width:760px;margin:14px 0 0;color:#ddf4ed;font-size:17px}.route{position:relative;margin-top:17px;font-weight:750}.metrics{position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:28px}.metric{padding:15px;border:1px solid rgba(255,255,255,.2);border-radius:14px;background:rgba(4,37,37,.34);backdrop-filter:blur(8px)}.metric span{display:block;color:#bde7dc;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.metric strong{display:block;margin-top:3px;font:700 25px/1.2 Georgia,serif}.section-nav{position:sticky;top:0;z-index:20;display:flex;gap:6px;overflow-x:auto;margin:14px 0 20px;padding:8px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.94);box-shadow:0 7px 18px rgba(15,63,61,.08);scrollbar-width:none}.section-nav::-webkit-scrollbar{display:none}.section-nav a{flex:0 0 auto;padding:9px 12px;border-radius:9px;color:var(--ink);font-weight:750;text-decoration:none}.section-nav a:hover{background:var(--sea)}.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:22px;align-items:start}.content{min-width:0}.rail{position:sticky;top:78px}.panel,.rail-card{margin-bottom:22px;border:1px solid var(--line);border-radius:var(--r);background:var(--paper);box-shadow:0 8px 22px rgba(20,68,66,.06)}.panel{padding:24px}.rail-card{padding:18px}.section-head{display:flex;gap:20px;align-items:end;justify-content:space-between;margin-bottom:18px}.section-head h2{margin:0;font:700 clamp(25px,3vw,36px)/1.08 Georgia,serif}.section-head p{max-width:620px;margin:5px 0 0;color:var(--muted)}.tiny{font-size:12px;color:var(--muted)}.status-row,.chips{display:flex;flex-wrap:wrap;gap:7px}.chip,.status{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:var(--mist);color:#34575a;font-size:12px;font-weight:700}.status{background:var(--sea);color:var(--teal2)}.status.warn{background:#fff1e3;color:var(--warn)}.selection-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.pick{position:relative;min-height:185px;overflow:hidden;border-radius:14px;background:#174b4c;color:white}.pick img{position:absolute;width:100%;height:100%;object-fit:cover}.pick:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(3,30,31,.92),rgba(3,30,31,.05) 68%)}.pick-copy{position:absolute;z-index:2;inset:auto 13px 12px}.pick-copy small{display:block;color:#bce8dc;text-transform:uppercase;letter-spacing:.08em}.pick-copy strong{display:block;font:700 18px/1.2 Georgia,serif}.group+.group{margin-top:28px}.group-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 12px}.group-title h3{margin:0;font:700 21px/1.2 Georgia,serif}.card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.choice{overflow:hidden;border:1px solid var(--line);border-radius:16px;background:#fff;transition:border-color .18s,box-shadow .18s,transform .18s}.choice:hover{transform:translateY(-2px);box-shadow:var(--shadow)}.choice.selected{border:2px solid var(--teal);box-shadow:0 0 0 3px rgba(12,102,98,.11)}.gallery{position:relative;background:#173f42}.hero-img{display:block;width:100%;aspect-ratio:16/9;object-fit:cover}.image-fallback{display:none;aspect-ratio:16/9;place-items:center;padding:20px;color:white;text-align:center;background:linear-gradient(135deg,#174d4b,#2f8374)}.gallery.failed .hero-img{display:none}.gallery.failed .image-fallback{display:grid}.image-credit{position:absolute;right:8px;top:8px;max-width:70%;padding:4px 7px;border-radius:7px;background:rgba(4,32,34,.76);color:white;font-size:10px;text-decoration:none}.thumbs{position:absolute;left:8px;bottom:8px;display:flex;gap:6px}.thumb{width:48px;height:38px;padding:0;border:2px solid rgba(255,255,255,.7);border-radius:7px;overflow:hidden;background:#173f42;cursor:pointer}.thumb[aria-current=true]{border-color:var(--gold)}.thumb img{width:100%;height:100%;object-fit:cover}.choice-body{padding:16px}.title-row{display:flex;align-items:start;justify-content:space-between;gap:12px}.title-row h4{margin:0;font:700 21px/1.12 Georgia,serif}.price{flex:0 0 auto;color:var(--teal2);font-weight:850}.subline{margin:5px 0 10px;color:var(--muted);font-size:13px}.desc{margin:10px 0}.fit{padding:10px 12px;border-left:3px solid var(--gold);background:#fff9ed}.fit strong{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.07em}.details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin:12px 0}.detail{padding:8px;border-radius:9px;background:var(--mist);font-size:12px}.detail b{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.lists{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}.listbox{padding:10px;border-radius:10px;background:#f4faf7}.listbox.caution{background:#fff6ed}.listbox b{font-size:12px}.listbox ul{margin:4px 0 0;padding-left:17px;font-size:12px}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:13px}.btn{min-height:42px;padding:9px 13px;border:1px solid var(--teal);border-radius:10px;background:var(--teal);color:#fff;font-weight:800;cursor:pointer}.btn:hover{background:var(--teal2)}.btn.secondary{background:white;color:var(--teal)}.btn.compare.active{background:var(--sea);color:var(--teal2)}.link-btn{display:inline-flex;align-items:center;min-height:42px;padding:9px 4px;font-weight:750}.day-list{display:grid;gap:12px}.day{display:grid;grid-template-columns:120px 1fr;gap:14px;padding:14px;border:1px solid var(--line);border-radius:13px}.day-date{font-weight:800;color:var(--teal2)}.day-date small{display:block;color:var(--muted);font-weight:600}.day-slots{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.slot{padding:9px;background:var(--mist);border-radius:9px}.slot b{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}.day-meta{margin-top:8px;color:var(--muted);font-size:12px}.budget-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.assumption{display:grid;grid-template-columns:1fr 120px;gap:12px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.assumption input{width:100%;padding:9px;border:1px solid var(--line);border-radius:8px}.line-item{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)}.source-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.source{padding:12px;border:1px solid var(--line);border-radius:10px}.source a{font-weight:800}.rail h3{margin:0 0 10px;font:700 20px Georgia,serif}.progress{height:8px;overflow:hidden;border-radius:999px;background:#dce9e5}.progress span{display:block;height:100%;background:var(--gold)}.rail-total{margin:13px 0;font:700 31px Georgia,serif}.decision-list{margin:12px 0;padding:0;list-style:none}.decision-list li{padding:8px 0;border-bottom:1px solid var(--line);font-size:13px}.reset{width:100%;margin-top:10px}.compare-drawer{position:fixed;z-index:40;left:50%;bottom:14px;transform:translateX(-50%);width:min(1120px,calc(100% - 28px));max-height:72vh;overflow:auto;padding:16px;border:1px solid #8cbab0;border-radius:16px;background:#fff;box-shadow:0 22px 70px rgba(2,35,36,.35)}.compare-drawer[hidden]{display:none}.compare-head{display:flex;justify-content:space-between;gap:16px;align-items:center}.compare-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px}.compare-col{padding:12px;border:1px solid var(--line);border-radius:10px}.compare-col img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px}.compare-col h4{margin:8px 0}.compare-col dl{font-size:12px}.compare-col dt{font-weight:800;color:var(--muted)}.compare-col dd{margin:0 0 7px}.footer{padding:28px;text-align:center;color:var(--muted)}.footer a{font-weight:800}@media(max-width:1000px){.metrics,.selection-strip{grid-template-columns:repeat(2,1fr)}.layout{grid-template-columns:1fr}.rail{position:static;display:grid;grid-template-columns:1fr 1fr;gap:14px}.rail-card{margin:0}}@media(max-width:680px){.wrap{padding:10px}.mast{padding:24px 18px}.metrics,.selection-strip,.card-grid,.budget-grid,.source-list,.rail,.compare-grid{grid-template-columns:1fr}.panel{padding:17px}.day{grid-template-columns:1fr}.day-slots{grid-template-columns:1fr}.details,.lists{grid-template-columns:1fr}.section-head{align-items:start;flex-direction:column}.thumb{width:43px;height:34px}.compare-drawer{bottom:6px}.metric strong{font-size:22px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.choice{transition:none}}@media print{body{background:white}.wrap{max-width:none}.section-nav,.actions,.thumbs,.image-credit,.rail .reset,.compare-drawer,.footer{display:none!important}.layout{display:block}.rail{position:static}.choice:not(.selected){display:none}.panel,.rail-card,.mast{box-shadow:none;break-inside:avoid}.mast{background:#174d4b!important;-webkit-print-color-adjust:exact}.card-grid{grid-template-columns:1fr 1fr}.hero-img{max-height:180px}.panel{padding:14px}}
:root{--shadow:0 15px 38px rgba(0,0,0,.12)}.mast>.eyebrow{position:relative;margin:0 0 9px;color:#bde7dc;font-size:13px;font-weight:750;text-transform:none;letter-spacing:0}.section-head .eyebrow{display:none}.panel,.rail-card{box-shadow:0 8px 22px rgba(0,0,0,.06)}.section-nav{box-shadow:0 7px 18px rgba(0,0,0,.08)}.compare-drawer{border-color:#8cbab0;box-shadow:0 18px 45px rgba(0,0,0,.28)}
</style>
</head>
<body>
<div class="wrap" id="app"></div>
<div class="compare-drawer" id="compareDrawer" hidden aria-live="polite"></div>
<script id="trip-data" type="application/json">__TRIP_DATA_JSON__</script>
<script>
(()=>{'use strict';
const original=JSON.parse(document.getElementById('trip-data').textContent),data=structuredClone(original),app=document.getElementById('app'),drawer=document.getElementById('compareDrawer');
const comparisons={hotel:new Set(),activity:new Set(),restaurant:new Set(),transport:new Set()};
const e=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
const money=n=>new Intl.NumberFormat(undefined,{style:'currency',currency:data.meta.currency,maximumFractionDigits:0}).format(Number(n||0));
const list=(items,kind='')=>items?.length?`<div class="listbox ${kind}"><b>${kind?'Watch for':'Good to know'}</b><ul>${items.map(x=>`<li>${e(x)}</li>`).join('')}</ul></div>`:'';
const chips=items=>items?.length?`<div class="chips">${items.map(x=>`<span class="chip">${e(x)}</span>`).join('')}</div>`:'';
const detail=(label,value)=>value!==undefined&&value!==null&&value!==''?`<div class="detail"><b>${e(label)}</b>${e(value)}</div>`:'';
function imageUrl(item){return item?.images?.[0]?.url||''}
function gallery(item){const imgs=item.images||[],first=imgs[0]||{};return `<div class="gallery" data-gallery="${e(item.id)}"><img class="hero-img" src="${e(first.url)}" alt="${e(first.alt||item.name)}" loading="lazy"><div class="image-fallback">Image unavailable<br><strong>${e(item.name)}</strong></div>${first.source_url?`<a class="image-credit" href="${e(first.source_url)}" target="_blank" rel="noreferrer">Image: ${e(first.credit)}</a>`:''}<div class="thumbs">${imgs.map((im,i)=>`<button class="thumb" data-action="image" data-item="${e(item.id)}" data-index="${i}" aria-label="Show image ${i+1} for ${e(item.name)}" aria-current="${i===0}"><img src="${e(im.url)}" alt=""></button>`).join('')}</div></div>`}
function allItems(type){if(type==='hotel')return data.hotels.flatMap(g=>g.options);if(type==='activity')return data.activities;if(type==='restaurant')return data.restaurants;return data.transport.flatMap(g=>g.options)}
function optionCard(item,type,group=''){const selected=!!item.selected,compare=comparisons[type].has(item.id);let details='';
if(type==='hotel')details=[detail('Stay',`${item.nights} nights · ${item.room||'Room TBD'}`),detail('Rate',item.nightly_rate?`${money(item.nightly_rate)} / night`:'See total'),detail('Neighborhood',item.neighborhood_vibe),detail('Walkability',item.walkability),detail('Cancellation',item.cancellation),detail('Evidence',item.checked_at||item.status)].join('');
if(type==='activity')details=[detail('When',`${item.day}${item.start_times?' · '+item.start_times:''}`),detail('Duration',item.duration),detail('Group',item.group_size),detail('Meeting point',item.meeting_point),detail('Cancellation',item.cancellation),detail('Evidence',item.checked_at||item.status)].join('');
if(type==='restaurant')details=[detail('Meal',item.meal),detail('Price',item.price_level),detail('Reservation',item.reservation),detail('Signature',item.signature_dishes?.join(' · ')),detail('Dietary',item.dietary?.join(' · ')),detail('Evidence',item.checked_at||item.status)].join('');
if(type==='transport')details=[detail('Mode',item.mode),detail('Duration',item.duration),detail('Frequency',item.frequency),detail('Transfers',item.transfer_count),detail('Luggage',item.luggage),detail('Comfort',item.comfort)].join('');
const rating=item.rating?`★ ${e(item.rating)}${item.review_count?` · ${e(item.review_count)} reviews`:''}`:'';
const action=type==='hotel'||type==='transport'?`<button class="btn" data-action="choose" data-type="${type}" data-group="${e(group)}" data-id="${e(item.id)}">${selected?'Selected':'Choose this'}</button>`:`<button class="btn" data-action="toggle" data-type="${type}" data-id="${e(item.id)}">${selected?'Shortlisted':'Add to shortlist'}</button>`;
return `<article class="choice ${selected?'selected':''}" data-card="${e(item.id)}">${gallery(item)}<div class="choice-body"><div class="title-row"><h4>${e(item.name)}</h4><span class="price">${money(item.amount)}</span></div><div class="subline">${e(item.location||group)}${rating?' · '+rating:''}</div><p class="desc">${e(item.description)}</p><div class="fit"><strong>Why it fits this trip</strong>${e(item.why_fit)}</div>${chips(item.amenities||item.includes)}<div class="details">${details}</div><div class="lists">${list(item.pros)}${list(item.cautions,'caution')}</div><div class="actions">${action}<button class="btn secondary compare ${compare?'active':''}" data-action="compare" data-type="${type}" data-id="${e(item.id)}">${compare?'Comparing':'Compare'}</button>${item.url?`<a class="link-btn" href="${e(item.url)}" target="_blank" rel="noreferrer">Verify / book ↗</a>`:''}${item.direct_url?`<a class="link-btn" href="${e(item.direct_url)}" target="_blank" rel="noreferrer">Direct operator ↗</a>`:''}</div></div></article>`}
function totals(){let subtotal=data.assumptions.reduce((s,x)=>s+Number(x.value),0)+data.fixed_costs.filter(x=>['included','observed','estimated'].includes(x.status)).reduce((s,x)=>s+Number(x.amount),0);subtotal+=data.hotels.flatMap(x=>x.options).filter(x=>x.selected).reduce((s,x)=>s+Number(x.amount),0);subtotal+=data.activities.filter(x=>x.selected).reduce((s,x)=>s+Number(x.amount),0);subtotal+=data.restaurants.filter(x=>x.selected&&x.budget_mode==='additive').reduce((s,x)=>s+Number(x.amount),0);subtotal+=data.transport.flatMap(x=>x.options).filter(x=>x.selected).reduce((s,x)=>s+Number(x.amount),0);return{subtotal,total:subtotal*(1+Number(data.meta.contingency_rate))}}
function selectedPicks(){const items=[...data.hotels.flatMap(x=>x.options).filter(x=>x.selected),...data.activities.filter(x=>x.selected).slice(0,1),...data.restaurants.filter(x=>x.selected).slice(0,1),...data.transport.flatMap(x=>x.options).filter(x=>x.selected).slice(0,1)].slice(0,4);return items.map(x=>`<div class="pick">${imageUrl(x)?`<img src="${e(imageUrl(x))}" alt="">`:''}<div class="pick-copy"><small>${e(x.mode||x.cuisine||x.source_type||'Selected')}</small><strong>${e(x.name)}</strong></div></div>`).join('')||'<p>No selections yet.</p>'}
function metrics(){const t=totals(),target=data.meta.target,selected=data.activities.filter(x=>x.selected).length+data.restaurants.filter(x=>x.selected).length+data.hotels.length+data.transport.length,totalChoices=data.activities.length+data.restaurants.length+data.hotels.length+data.transport.length,pct=Math.min(100,Math.round(selected/Math.max(1,totalChoices)*100));return{html:`<div class="metric"><span>Current total</span><strong>${money(t.total)}</strong></div><div class="metric"><span>Per traveler</span><strong>${money(t.total/data.meta.travelers)}</strong></div><div class="metric"><span>Against target</span><strong>${target==null?'No target':money(target-t.total)}</strong></div><div class="metric"><span>Decisions shaped</span><strong>${pct}%</strong></div>`,pct,total:t.total}}
function render(){const m=metrics();document.title=data.meta.title+' — Trip workspace';app.innerHTML=`<header class="mast"><p class="eyebrow">Vacation Planner Pro · Decision workspace</p><h1>${e(data.meta.title)}</h1><p class="subtitle">${e(data.meta.subtitle||'Compare the trip, shape the details, and keep the full plan in one place.')}</p><div class="route">${e(data.meta.route)} · ${data.meta.travelers} travelers · ${data.meta.trip_days} days</div><div class="metrics">${m.html}</div></header>
<nav class="section-nav" aria-label="Trip workspace sections"><a href="#overview">Overview</a><a href="#itinerary">Itinerary</a><a href="#stays">Stays</a><a href="#experiences">Experiences</a><a href="#dining">Dining</a><a href="#transport">Transport</a><a href="#budget">Budget</a><a href="#sources">Sources</a></nav>
<div class="layout"><main class="content"><section class="panel" id="overview"><div class="section-head"><div><p class="eyebrow">The trip at a glance</p><h2>Your current travel story</h2><p>These are the choices presently shaping the plan. Change any option below and this view, the budget, and your decision summary update together.</p></div><span class="status ${data.meta.readiness==='not_ready'?'warn':''}">${e(data.meta.readiness||'planning')}</span></div><div class="selection-strip">${selectedPicks()}</div></section>
${itinerarySection()}${hotelSection()}${activitySection()}${restaurantSection()}${transportSection()}${budgetSection()}${sourceSection()}</main>${rail(m)}</div><footer class="footer">Built for thoughtful travel by <a href="https://ko-fi.com/quietengine" target="_blank" rel="noreferrer">Quiet Engine</a> · <a href="https://quietengine.ai" target="_blank" rel="noreferrer">Website</a></footer>`;attachImageErrors();renderDrawer()}
function itinerarySection(){return `<section class="panel" id="itinerary"><div class="section-head"><div><p class="eyebrow">Day by day</p><h2>Itinerary</h2><p>Time-aware days with transfers, breathing room, and practical notes.</p></div></div><div class="day-list">${data.days.map(d=>`<article class="day"><div class="day-date">${e(d.label)}<small>${e(d.date)} · ${e(d.base)}</small></div><div><div class="day-slots"><div class="slot"><b>Morning</b>${e(d.morning)}</div><div class="slot"><b>Afternoon</b>${e(d.afternoon)}</div><div class="slot"><b>Evening</b>${e(d.evening)}</div></div><div class="day-meta"><b>Transit:</b> ${e(d.transit)} · <b>${d.commitments} timed commitment${d.commitments===1?'':'s'}</b><br>${e(d.notes)}</div></div></article>`).join('')}</div></section>`}
function hotelSection(){return `<section class="panel" id="stays"><div class="section-head"><div><p class="eyebrow">Sleep well, locate better</p><h2>Stays</h2><p>Compare the room, neighborhood, walkability, flexibility, and full party cost—not just the nightly headline.</p></div></div>${data.hotels.map(g=>`<div class="group"><div class="group-title"><h3>${e(g.base)}</h3><span class="status">Choose one</span></div><div class="card-grid">${g.options.map(x=>optionCard(x,'hotel',g.base)).join('')}</div></div>`).join('')}</section>`}
function activitySection(){return `<section class="panel" id="experiences"><div class="section-head"><div><p class="eyebrow">Tours, tickets & hidden gems</p><h2>Experiences</h2><p>Marketplace and direct-operator details sit together so the actual experience—not the listing title—wins.</p></div><span class="status">${data.activities.filter(x=>x.selected).length} shortlisted</span></div><div class="card-grid">${data.activities.map(x=>optionCard(x,'activity')).join('')}</div></section>`}
function restaurantSection(){return `<section class="panel" id="dining"><div class="section-head"><div><p class="eyebrow">Meals worth planning around</p><h2>Dining</h2><p>Shortlist places by neighborhood, dishes, dietary fit, reservation friction, and atmosphere.</p></div><span class="status">${data.restaurants.filter(x=>x.selected).length} shortlisted</span></div><div class="card-grid">${data.restaurants.map(x=>optionCard(x,'restaurant')).join('')||'<p>No dining decisions are needed for this trip.</p>'}</div></section>`}
function transportSection(){return `<section class="panel" id="transport"><div class="section-head"><div><p class="eyebrow">Door to door, not station to station</p><h2>Transport</h2><p>Compare real journey time, transfers, luggage handling, comfort, and party-total cost.</p></div></div>${data.transport.map(g=>`<div class="group"><div class="group-title"><h3>${e(g.leg)}</h3><span class="status">Choose one</span></div><div class="card-grid">${g.options.map(x=>optionCard(x,'transport',g.leg)).join('')}</div></div>`).join('')||'<p>No transport decision is needed.</p>'}</section>`}
function budgetSection(){const t=totals();return `<section class="panel" id="budget"><div class="section-head"><div><p class="eyebrow">Transparent trip math</p><h2>Budget lab</h2><p>Adjust the assumptions that matter. Choices above feed this total automatically.</p></div><span class="status">${Math.round(data.meta.contingency_rate*100)}% contingency</span></div><div class="budget-grid"><div><h3>Editable assumptions</h3>${data.assumptions.map(x=>`<label class="assumption"><span><b>${e(x.label)}</b><br><small>${e(x.note||x.category)}</small></span><input type="number" min="${x.min}" max="${x.max}" step="${x.step}" value="${x.value}" data-action="assumption" data-id="${e(x.id)}" aria-label="${e(x.label)}"></label>`).join('')}</div><div><h3>Included selections</h3>${budgetLines()}<div class="line-item"><b>Subtotal</b><b>${money(t.subtotal)}</b></div><div class="line-item"><b>Contingency</b><b>${money(t.total-t.subtotal)}</b></div><div class="line-item"><strong>Total</strong><strong>${money(t.total)}</strong></div></div></div></section>`}
function budgetLines(){const lines=[...data.fixed_costs.filter(x=>['included','observed','estimated'].includes(x.status)).map(x=>[x.label,x.amount]),...data.hotels.flatMap(x=>x.options).filter(x=>x.selected).map(x=>[x.name,x.amount]),...data.activities.filter(x=>x.selected).map(x=>[x.name,x.amount]),...data.restaurants.filter(x=>x.selected&&x.budget_mode==='additive').map(x=>[x.name,x.amount]),...data.transport.flatMap(x=>x.options).filter(x=>x.selected).map(x=>[x.name,x.amount])];return lines.map(x=>`<div class="line-item"><span>${e(x[0])}</span><span>${money(x[1])}</span></div>`).join('')}
function sourceSection(){return `<section class="panel" id="sources"><div class="section-head"><div><p class="eyebrow">Evidence behind the plan</p><h2>Sources</h2><p>Links are here to verify or book—not to make you reconstruct the recommendation.</p></div></div><div class="source-list">${data.sources.map(s=>`<div class="source"><span class="status">${e(s.type)}</span><br><a href="${e(s.url)}" target="_blank" rel="noreferrer">${e(s.label)} ↗</a><div class="tiny">Checked ${e(s.checked_at)}${s.note?' · '+e(s.note):''}</div></div>`).join('')}</div></section>`}
function rail(m){const hotels=data.hotels.map(g=>g.options.find(x=>x.selected)?.name).filter(Boolean),transport=data.transport.map(g=>g.options.find(x=>x.selected)?.name).filter(Boolean);return `<aside class="rail" aria-label="Current decision summary"><div class="rail-card"><h3>Decision pulse</h3><div class="progress"><span style="width:${m.pct}%"></span></div><div class="rail-total">${money(m.total)}</div><div class="tiny">${money(m.total/data.meta.travelers)} per traveler · saved in this browser</div><ul class="decision-list"><li><b>Stays:</b> ${e(hotels.join(' · ')||'Choose')}</li><li><b>Experiences:</b> ${data.activities.filter(x=>x.selected).length} shortlisted</li><li><b>Dining:</b> ${data.restaurants.filter(x=>x.selected).length} shortlisted</li><li><b>Transport:</b> ${e(transport.join(' · ')||'Choose')}</li></ul><button class="btn secondary reset" data-action="reset">Reset recommendations</button></div><div class="rail-card"><h3>Research health</h3><p><b>Checked:</b> ${e(data.meta.researched_at)}</p><p><b>Confidence:</b> ${e(data.meta.confidence||'See item details')}</p><p><b>Largest uncertainty:</b><br>${e(data.meta.largest_uncertainty||'None recorded')}</p></div></aside>`}
function save(){try{localStorage.setItem('vpp:'+e(data.meta.id||data.meta.title),JSON.stringify({assumptions:data.assumptions.map(x=>[x.id,x.value]),hotels:data.hotels.map(g=>g.options.find(x=>x.selected)?.id),activities:data.activities.filter(x=>x.selected).map(x=>x.id),restaurants:data.restaurants.filter(x=>x.selected).map(x=>x.id),transport:data.transport.map(g=>g.options.find(x=>x.selected)?.id)}))}catch{}}
function restore(){try{const raw=localStorage.getItem('vpp:'+e(data.meta.id||data.meta.title));if(!raw)return;const s=JSON.parse(raw),set=(a)=>new Set(a||[]);(s.assumptions||[]).forEach(([id,v])=>{const x=data.assumptions.find(y=>y.id===id);if(x)x.value=v});const hs=set(s.hotels);data.hotels.forEach(g=>g.options.forEach(x=>x.selected=hs.has(x.id)));const as=set(s.activities);data.activities.forEach(x=>x.selected=as.has(x.id));const rs=set(s.restaurants);data.restaurants.forEach(x=>x.selected=rs.has(x.id));const ts=set(s.transport);data.transport.forEach(g=>g.options.forEach(x=>x.selected=ts.has(x.id)))}catch{}}
function attachImageErrors(){document.querySelectorAll('.hero-img').forEach(img=>img.addEventListener('error',()=>img.closest('.gallery')?.classList.add('failed'),{once:true}))}
function renderDrawer(){const active=Object.entries(comparisons).find(([,s])=>s.size);if(!active){drawer.hidden=true;return}const[type,ids]=active,items=allItems(type).filter(x=>ids.has(x.id));drawer.hidden=false;drawer.innerHTML=`<div class="compare-head"><div><b>Compare ${e(type)} choices</b><div class="tiny">Up to three like-for-like options</div></div><button class="btn secondary" data-action="clear-compare">Close comparison</button></div><div class="compare-grid">${items.map(x=>`<div class="compare-col"><img src="${e(imageUrl(x))}" alt=""><h4>${e(x.name)}</h4><strong>${money(x.amount)}</strong><dl><dt>Why it fits</dt><dd>${e(x.why_fit)}</dd><dt>Location / timing</dt><dd>${e(x.location||x.day||x.duration||'See details')}</dd><dt>Best for</dt><dd>${e(x.pros?.join(' · ')||'See card')}</dd><dt>Watch for</dt><dd>${e(x.cautions?.join(' · ')||'None recorded')}</dd></dl></div>`).join('')}</div>`}
document.addEventListener('click',ev=>{const el=ev.target.closest('[data-action]');if(!el)return;const a=el.dataset.action,id=el.dataset.id,type=el.dataset.type;if(a==='choose'){const groups=type==='hotel'?data.hotels:data.transport,group=groups.find(g=>(g.base||g.leg)===el.dataset.group);group.options.forEach(x=>x.selected=x.id===id);save();render()}if(a==='toggle'){const item=allItems(type).find(x=>x.id===id);item.selected=!item.selected;save();render()}if(a==='compare'){Object.entries(comparisons).forEach(([k,s])=>{if(k!==type)s.clear()});const s=comparisons[type];if(s.has(id))s.delete(id);else if(s.size<3)s.add(id);render()}if(a==='clear-compare'){Object.values(comparisons).forEach(s=>s.clear());render()}if(a==='reset'){Object.assign(data,structuredClone(original));Object.values(comparisons).forEach(s=>s.clear());try{localStorage.removeItem('vpp:'+e(data.meta.id||data.meta.title))}catch{}render()}if(a==='image'){const item=Object.values({hotel:allItems('hotel'),activity:data.activities,restaurant:data.restaurants,transport:allItems('transport')}).flat().find(x=>x.id===el.dataset.item),im=item?.images?.[Number(el.dataset.index)],g=el.closest('.gallery');if(im&&g){g.classList.remove('failed');const hero=g.querySelector('.hero-img'),credit=g.querySelector('.image-credit');hero.src=im.url;hero.alt=im.alt||item.name;if(credit){credit.href=im.source_url;credit.textContent='Image: '+im.credit}g.querySelectorAll('.thumb').forEach(x=>x.setAttribute('aria-current',String(x===el)))}}});
document.addEventListener('input',ev=>{const el=ev.target.closest('[data-action="assumption"]');if(!el)return;const x=data.assumptions.find(y=>y.id===el.dataset.id);if(x){x.value=Math.min(x.max,Math.max(x.min,Number(el.value)||0));save();render()}});
restore();render();
})();
</script>
</body>
</html>
SHA-256: ae70633db3284f2359596bd69416508d01e54d6d3d33a33ded23738219eccd5c