← back to Charge And Explore

public/dashboard.html

424 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>My Car — Dashboard · Charge &amp; Explore</title>
<meta name="theme-color" content="#0a7d5a">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.svg">
<style>
  :root{
    --ink:#0f1720; --sub:#5b6b7a; --line:#e6ebf0; --bg:#f7f9fb; --card:#fff;
    --accent:#0a7d5a; --accent-soft:#e6f5ef; --amber:#b8860b; --red:#b23b3b; --blue:#4285F4;
  }
  *{box-sizing:border-box}
  body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:var(--ink);background:var(--bg);
       padding-bottom:calc(64px + env(safe-area-inset-bottom))}
  header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:5}
  .brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;color:var(--ink);text-decoration:none}
  .brand .mk{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,var(--accent),#0bb37f);display:grid;place-items:center;color:#fff;font-size:16px}
  .conf{font-size:12px;color:var(--sub)}
  .wrap{max-width:760px;margin:0 auto;padding:14px 14px 30px}

  /* hero: battery ring */
  .hero{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px;display:flex;align-items:center;gap:16px}
  .ring{width:104px;height:104px;flex:0 0 auto}
  .ring text{font:800 22px -apple-system,BlinkMacSystemFont,sans-serif}
  .vname{font-size:18px;font-weight:800}
  .hstate{font-size:13px;color:var(--sub);margin-top:2px}
  .hstate b{color:var(--accent)}
  .hlinks{margin-top:8px;display:flex;flex-wrap:wrap;gap:8px}
  .pill{display:inline-block;padding:6px 12px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:12.5px;font-weight:600;text-decoration:none;cursor:pointer}
  .pill.go{background:var(--accent);border-color:var(--accent);color:#fff}

  /* insight cards */
  .insights{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-top:10px}
  .icard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px}
  .icard span{display:block;font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.05em}
  .icard b{font-size:19px;font-variant-numeric:tabular-nums}
  .icard small{display:block;font-size:11.5px;color:var(--sub)}
  .icard a{color:inherit;text-decoration:none}

  .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin:20px 0 10px}
  .bar h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);margin:0}

  /* spec search */
  .search{width:100%;padding:11px 14px;border:1px solid var(--line);border-radius:12px;font-size:15px;background:#fff;margin-top:6px}
  .search:focus{outline:2px solid var(--accent-soft);border-color:var(--accent)}

  /* spec sections */
  details.sec{background:var(--card);border:1px solid var(--line);border-radius:14px;margin-top:10px;overflow:hidden}
  details.sec>summary{list-style:none;cursor:pointer;padding:13px 16px;font-weight:800;font-size:14.5px;display:flex;align-items:center;gap:8px}
  details.sec>summary::-webkit-details-marker{display:none}
  details.sec>summary .n{margin-left:auto;font-size:11.5px;color:var(--sub);font-weight:600}
  details.sec>summary .car{transition:transform .15s;color:var(--sub)}
  details.sec[open]>summary .car{transform:rotate(90deg)}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;padding:0 12px 12px}
  .chip{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:9px 11px;font-size:13px;min-width:0}
  .chip span{display:block;font-size:10.5px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .chip b{font-size:14.5px;font-variant-numeric:tabular-nums;word-break:break-word}
  .chip.hide{display:none}
  .chip a{color:var(--blue);text-decoration:none}
  .rawlink{font-size:12px;color:var(--sub);padding:0 16px 12px;display:block}
  .rawlink a{color:var(--blue);text-decoration:none}

  /* trip cost calculator */
  .calc{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin-top:10px}
  .calc .row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
  .calc label{font-size:12px;color:var(--sub)}
  .calc input{width:88px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;font-size:15px}
  .calc .out{font-size:20px;font-weight:800;color:var(--accent);margin-left:auto}

  .empty{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:26px;text-align:center}
  .gbtn{display:inline-block;padding:10px 16px;border-radius:10px;background:var(--accent);color:#fff;font-weight:700;font-size:13.5px;text-decoration:none;border:0;cursor:pointer}
  .gbtn.sec{background:#fff;color:var(--ink);border:1px solid var(--line)}
  .note{font-size:12.5px;color:var(--amber);margin-top:8px}

  /* charge sessions */
  .sess{background:var(--card);border:1px solid var(--line);border-radius:14px;margin-top:10px;padding:6px 0}
  .sess .srow{display:flex;align-items:center;gap:10px;padding:9px 16px;border-top:1px solid var(--line);font-size:13.5px}
  .sess .srow:first-child{border-top:0}
  .sess .zap{color:var(--accent);font-size:16px}
  .sess .when{color:var(--sub);font-size:12px;margin-left:auto;white-space:nowrap}

  /* bottom nav (mobile app feel) */
  .tabbar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--line);display:flex;z-index:10;
          padding-bottom:env(safe-area-inset-bottom)}
  .tabbar a{flex:1;text-align:center;padding:9px 0 7px;font-size:11px;color:var(--sub);text-decoration:none;font-weight:600}
  .tabbar a .ico{display:block;font-size:19px;line-height:1.2}
  .tabbar a.on{color:var(--accent)}
  @media(min-width:761px){ body{padding-bottom:0} .tabbar{position:static;max-width:760px;margin:20px auto 0;border:1px solid var(--line);border-radius:14px} }
</style>
</head>
<body>
<header>
  <a class="brand" href="/"><span class="mk">⚡</span>Charge &amp; Explore</a>
  <span class="conf" id="asof"></span>
</header>

<div class="wrap">
  <div id="page"><div class="empty conf">Loading your car…</div></div>
  <div id="upgrade"></div><!-- billing upgrade cards — renders ONLY when /api/billing/status says configured:true (TK-10227) -->
</div>

<nav class="tabbar">
  <a href="/dashboard" class="on"><span class="ico">🚗</span>Dashboard</a>
  <a href="/stats"><span class="ico">📈</span>Graphs</a>
  <a href="/"><span class="ico">🗺</span>Find a stop</a>
  <a href="/support.html"><span class="ico">💬</span>Support</a>
</nav>

<script>
// ---- Mobile dashboard: ALL specs (Dust2026 2026-08-04, TK-10219).
// Renders the ENTIRE raw Fleet-API vehicle_data payload dynamically — every
// field in charge_state / climate_state / drive_state / vehicle_state /
// vehicle_config / gui_settings — so new fields Tesla ships appear here with
// zero code changes. Asleep car falls back to the cached last-known summary.

const $=id=>document.getElementById(id);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

// ---- Multi-car picker (TK-10227). Shown ONLY when the account has >1 vehicle;
// choice persists in localStorage (ce-vin, shared with /stats) and rides the
// summary call as ?vin=. One car → no picker, exactly the old page.
function selVin(){ try{ return localStorage.getItem('ce-vin')||'' }catch{ return '' } }
function carPickerHtml(t,cur){
  const list=t.vehicles||[];
  if(list.length<2) return '';
  return '<div class="hlinks" id="carsel" style="margin-top:0;margin-bottom:10px">'+
    list.map(c=>'<button class="pill'+(c.vin===cur?' go':'')+'" data-vin="'+esc(c.vin)+'">🚗 '+esc(c.name||c.vin.slice(-6))+'</button>').join('')+
    '</div>';
}
function wireCarPicker(){
  document.querySelectorAll('#carsel .pill').forEach(b=>b.onclick=()=>{
    try{ localStorage.setItem('ce-vin', b.dataset.vin) }catch{}
    render();
  });
}

// --- humanize raw field names + values -------------------------------------
function label(k){
  return k.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase())
          .replace(/\bSoc\b/,'SOC').replace(/\bGps\b/,'GPS').replace(/\bApi\b/,'API')
          .replace(/\bAc\b/,'AC').replace(/\bUsb\b/,'USB').replace(/\bVin\b/,'VIN');
}
function fmtVal(key,v){
  if(v==null||v==='') return '—';
  if(typeof v==='boolean') return v?'✓ Yes':'✗ No';
  if(typeof v==='object') return esc(JSON.stringify(v)).slice(0,80);
  const k=key.toLowerCase();
  if(typeof v==='number'){
    if(/temp/.test(k)&&Math.abs(v)<80) return Math.round(v*9/5+32)+'°F <small style="color:var(--sub)">'+v.toFixed(1)+'°C</small>';
    if(/timestamp|_at$|as_of/.test(k)&&v>1e9){ const ms=v>1e12?v:v*1000; return new Date(ms).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
    if(/odometer/.test(k)) return Math.round(v).toLocaleString()+' mi';
    if(/range/.test(k)) return Math.round(v)+' mi';
    if(/power/.test(k)) return v+' kW';
    if(/voltage/.test(k)) return v+' V';
    if(/current|amps/.test(k)) return v+' A';
    if(/energy/.test(k)) return v+' kWh';
    if(/speed/.test(k)) return v+' mph';
    if(/pressure/.test(k)) return (v*14.5038).toFixed(1)+' psi'; // bar → psi (TPMS)
    if(/level|limit|soc|percent/.test(k)&&v>=0&&v<=100) return v+'%';
    return Number.isInteger(v)?v.toLocaleString():v.toFixed(2);
  }
  return esc(String(v));
}
// Any datum that HAS deeper data links to it (Steve's drill rule).
function drill(key,v,ctx){
  const k=key.toLowerCase();
  if((k==='latitude'||k==='longitude')&&ctx.lat!=null&&ctx.lon!=null)
    return `<a href="https://www.google.com/maps?q=${ctx.lat},${ctx.lon}" target="_blank" rel="noopener noreferrer">${fmtVal(key,v)}</a>`;
  if(/battery_level|battery_range/.test(k)) return `<a href="/stats">${fmtVal(key,v)}</a>`;
  if(k==='car_version') return `<a href="https://www.notateslaapp.com/software-updates/version/${esc(String(v).split(' ')[0])}" target="_blank" rel="noopener noreferrer">${fmtVal(key,v)}</a>`;
  return fmtVal(key,v);
}

const SECTIONS=[
  ['charge_state','🔋 Charging'],
  ['climate_state','🌡 Climate'],
  ['drive_state','📍 Drive & location'],
  ['vehicle_state','🚘 Vehicle state'],
  ['vehicle_config','⚙️ Configuration'],
  ['gui_settings','🖥 Display settings'],
];

function sectionHtml(name,title,obj,ctx,vin,open){
  const entries=Object.entries(obj).filter(([,v])=>typeof v!=='object'||v===null);
  const nested=Object.entries(obj).filter(([,v])=>typeof v==='object'&&v!==null&&!Array.isArray(v));
  for(const [nk,nv] of nested) for(const [k,v] of Object.entries(nv)) if(typeof v!=='object') entries.push([nk+'_'+k,v]);
  entries.sort((a,b)=>a[0]<b[0]?-1:1);
  const chips=entries.map(([k,v])=>
    `<div class="chip" data-q="${esc((k+' '+label(k)+' '+String(v)).toLowerCase())}"><span title="${esc(k)}">${esc(label(k))}</span><b>${drill(k,v,ctx)}</b></div>`).join('');
  return `<details class="sec" data-sec="${name}"${open?' open':''}><summary><span class="car">▶</span>${title}<span class="n">${entries.length} fields</span></summary>
    <div class="grid">${chips}</div>
    <span class="rawlink">source: <a href="/api/tesla/vehicle/${esc(vin)}/data" target="_blank" rel="noopener">raw vehicle_data JSON →</a></span>
  </details>`;
}

// --- battery ring hero ------------------------------------------------------
function ring(soc){
  const col=soc>=60?'var(--accent)':soc>=25?'var(--amber)':'var(--red)';
  const R=44,C=2*Math.PI*R,off=C*(1-Math.max(0,Math.min(100,soc))/100);
  return `<svg class="ring" viewBox="0 0 104 104" role="img" aria-label="Battery ${soc}%">
    <circle cx="52" cy="52" r="${R}" fill="none" stroke="var(--line)" stroke-width="9"/>
    <circle cx="52" cy="52" r="${R}" fill="none" stroke="${col}" stroke-width="9" stroke-linecap="round"
      stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}" transform="rotate(-90 52 52)"/>
    <text x="52" y="59" text-anchor="middle" fill="${col}">${soc}%</text>
  </svg>`;
}

// --- insights from history + current read ----------------------------------
function insightsHtml(v,hist){
  const s=v.stats||{};
  const cards=[];
  if(v.soc>0&&v.rangeMiles!=null)
    cards.push(['Projected full range', Math.round(v.rangeMiles/v.soc*100)+' mi','at 100% from today’s reading','/stats']);
  if(s.odometerMiles!=null)
    cards.push(['Odometer', s.odometerMiles.toLocaleString()+' mi','lifetime — cost calc below','#calc']);
  // charge sessions in the last 7 days from history
  if(hist&&hist.length){
    const runs=[]; let run=null;
    for(const p of hist){
      if(p.charging){ if(!run) run={t0:p.t,s0:p.soc}; run.t1=p.t; run.s1=p.soc; }
      else if(run){ runs.push(run); run=null; }
    }
    if(run) runs.push(run);
    const real=runs.filter(r=>r.s1>r.s0||((r.t1-r.t0)>10*60*1000));
    const gained=real.reduce((a,r)=>a+Math.max(0,r.s1-r.s0),0);
    cards.push(['Charge sessions · 7d', String(real.length), '+'+gained+'% battery added','#sessions']);
    window.__sessions=real;
  }
  return cards.map(([t,b,sm,href])=>`<div class="icard"><a href="${href}"><span>${t}</span><b>${b}</b><small>${sm}</small></a></div>`).join('');
}
function sessionsHtml(){
  const runs=(window.__sessions||[]).slice(-6).reverse();
  if(!runs.length) return '';
  const fmt=t=>new Date(t).toLocaleString(undefined,{weekday:'short',hour:'numeric',minute:'2-digit'});
  const rows=runs.map(r=>{
    const mins=Math.round((r.t1-r.t0)/60000);
    const dur=mins>=60?Math.floor(mins/60)+'h '+(mins%60)+'m':mins+'m';
    return `<div class="srow"><span class="zap">⚡</span><b>${r.s0}% → ${r.s1}%</b><span class="conf">${dur}</span><span class="when">${fmt(r.t0)}</span></div>`;
  }).join('');
  return `<div class="bar" id="sessions"><h2>Recent charge sessions</h2><span class="conf">from your battery history</span></div><div class="sess">${rows}</div>`;
}

// --- trip cost calculator (persisted) --------------------------------------
function calcHtml(){
  const g=k=>{try{return localStorage.getItem('ce-calc-'+k)}catch{return null}};
  return `<div class="bar" id="calc"><h2>Trip cost calculator</h2><span class="conf">edit any number</span></div>
  <div class="calc"><div class="row">
    <label>Miles<br><input id="c-mi" type="number" inputmode="decimal" value="${g('mi')||100}"></label>
    <label>mi/kWh<br><input id="c-eff" type="number" inputmode="decimal" step="0.1" value="${g('eff')||4.0}"></label>
    <label>$ / kWh<br><input id="c-price" type="number" inputmode="decimal" step="0.01" value="${g('price')||0.30}"></label>
    <span class="out" id="c-out"></span>
  </div><div class="conf" style="margin-top:8px">Home charging averages ~$0.15–0.20/kWh · Superchargers ~$0.30–0.50/kWh</div></div>`;
}
function wireCalc(){
  const upd=()=>{
    const mi=+$('c-mi').value||0, eff=+$('c-eff').value||1, pr=+$('c-price').value||0;
    $('c-out').textContent='$'+(mi/eff*pr).toFixed(2);
    try{['mi','eff','price'].forEach((k,i)=>localStorage.setItem('ce-calc-'+k,[$('c-mi'),$('c-eff'),$('c-price')][i].value))}catch{}
  };
  ['c-mi','c-eff','c-price'].forEach(id=>$(id).oninput=upd); upd();
}

// --- cached-stats fallback (asleep car) ------------------------------------
function fmtTempF(c){ return c==null?null:Math.round(c*9/5+32)+'°F'; }
function fmtMins(m){ if(m==null) return null; const h=Math.floor(m/60); return h?h+'h '+(m%60)+'m':m+' min'; }
function cachedChips(v){
  const s=v.stats||{};
  const rows=[['Battery',v.soc!=null?v.soc+'%':null],['Usable',s.usableSoc!=null?s.usableSoc+'%':null],
    ['Range',v.rangeMiles!=null?v.rangeMiles+' mi':null],['Est. range',s.estRangeMiles!=null?s.estRangeMiles+' mi':null],
    ['Charge limit',s.chargeLimit!=null?s.chargeLimit+'%':null],['Charging state',v.chargingState||null],
    ['Time to limit',fmtMins(s.minutesToFull)],['Energy added',s.energyAddedKWh!=null?s.energyAddedKWh+' kWh':null],
    ['Inside',fmtTempF(s.insideTempC)],['Outside',fmtTempF(s.outsideTempC)],
    ['Odometer',s.odometerMiles!=null?s.odometerMiles.toLocaleString()+' mi':null],
    ['Locked',s.locked==null?null:(s.locked?'🔒 Yes':'🔓 No')],['Sentry',s.sentry==null?null:(s.sentry?'👁 On':'Off')],
    ['Software',s.version||null]];
  return '<div class="grid" style="padding:0;margin-top:10px">'+rows.map(([l,val])=>
    `<div class="chip"><span>${l}</span><b>${val==null?'—':val}</b></div>`).join('')+'</div>';
}

// --- page assembly ----------------------------------------------------------
async function render(){
  const page=$('page');
  try{
    const vin=selVin();
    const t=await (await fetch('/api/tesla/summary'+(vin?'?vin='+encodeURIComponent(vin):''))).json();
    if(!t.connected){
      page.innerHTML='<div class="empty"><p style="margin-top:0">Connect your Tesla to see the full live dashboard — every spec your car reports, on your phone.</p>'+
        '<a class="gbtn" href="/auth/tesla/login">Connect Tesla</a> <a class="gbtn sec" href="/">Back to the app</a>'+
        '<p class="conf" style="margin-bottom:0">Drive a Ford, Hyundai, Kia, BMW, Rivian…? Multi-brand connect is coming — pick your EV model on the <a href="/">home page</a> meanwhile.</p></div>';
      return;
    }
    const v=t.vehicle;
    if(!v){ page.innerHTML=carPickerHtml(t,vin)+'<div class="empty conf">Connected — no vehicle found on the account. <button class="gbtn sec" onclick="render()">↻ Retry</button> <a class="gbtn" href="/auth/tesla/login">Re-connect Tesla</a></div>'; wireCarPicker(); return; }

    // history for insights (7 days)
    let hist=[]; try{ hist=(await (await fetch('/api/tesla/history?vin='+encodeURIComponent(v.vin||'')+'&hours=168')).json()).points||[] }catch{}

    const when=v.cachedAt?new Date(v.cachedAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):null;
    $('asof').textContent=v.asleep?('😴 asleep — last known'+(when?' · '+when:'')):'live from your car';

    let hero=
      carPickerHtml(t, v.vin||vin)+
      '<div class="hero">'+ring(v.soc??0)+
        '<div style="min-width:0"><div class="vname">'+esc(v.name||'My Tesla')+'</div>'+
        '<div class="hstate">'+(v.chargingState==='Charging'?'<b>⚡ Charging now</b>':esc(v.chargingState||'Parked'))+
        (v.rangeMiles!=null?' · ~'+v.rangeMiles+' mi range':'')+'</div>'+
        '<div class="hlinks"><button class="pill go" id="refresh">↻ Refresh</button>'+
        '<a class="pill" href="/stats">📈 Graphs</a></div></div>'+
      '</div>'+
      '<div class="insights">'+insightsHtml(v,hist)+'</div>';

    // Full raw payload — the "ALL specs" part. Asleep cars 408 here; fall back to cached chips.
    let body='';
    try{
      const dr=await fetch('/api/tesla/vehicle/'+encodeURIComponent(v.vin)+'/data');
      if(dr.ok){
        const dj=await dr.json(); const r=dj.response||{};
        const ds=r.drive_state||{}; const ctx={lat:ds.latitude??ds.active_route_latitude??null,lon:ds.longitude??ds.active_route_longitude??null};
        if(ctx.lat!=null) hero+=
          '<div class="bar"><h2>Location</h2></div><div class="calc"><div class="row">'+
          '<span>📍 '+ctx.lat.toFixed(5)+', '+ctx.lon.toFixed(5)+(ds.speed?' · '+ds.speed+' mph':'')+'</span>'+
          '<a class="pill go" style="margin-left:auto" href="https://www.google.com/maps?q='+ctx.lat+','+ctx.lon+'" target="_blank" rel="noopener noreferrer">Open in Maps →</a></div></div>';
        body+='<div class="bar"><h2>All specs</h2><span class="conf" id="speccount"></span></div>'+
          '<input class="search" id="q" type="search" placeholder="Search any spec — try tire, door, heat, limit…">';
        let total=0;
        SECTIONS.forEach(([key,title],i)=>{ const o=r[key]; if(o&&typeof o==='object'){ body+=sectionHtml(key,title,o,ctx,v.vin,i===0); total+=Object.keys(o).length; } });
        body+='<span data-total="'+total+'" id="totalmark"></span>';
      } else if(dr.status===408||dr.status===502){
        body+='<div class="note">😴 The car is asleep — showing the last-known readings below. Opening the Tesla app (or charging) wakes it; we never wake it automatically, that costs you range. '+
          '<button class="pill go" id="wakebtn" style="margin-left:6px">⚡ Wake my car</button></div>'+cachedChips(v);
      } else {
        body+='<div class="note">Full spec read unavailable (HTTP '+dr.status+') — showing last-known readings.</div>'+cachedChips(v);
      }
    }catch{ body+='<div class="note">Full spec read failed — showing last-known readings.</div>'+cachedChips(v); }

    page.innerHTML=hero+body+sessionsHtml()+calcHtml();
    wireCalc();
    wireCarPicker();
    $('refresh').onclick=render;
    const wk=$('wakebtn');
    if(wk) wk.onclick=async()=>{
      if(!confirm('Waking the car uses a small amount of battery range. Wake now?')) return;
      wk.disabled=true; wk.textContent='⚡ Waking… (~20s)';
      try{ await fetch('/api/tesla/wake'+(v.vin?'?vin='+encodeURIComponent(v.vin):''),{method:'POST'}); }catch{}
      for(let i=0;i<6;i++){
        await new Promise(r2=>setTimeout(r2,10000));
        try{ const t=await fetch('/api/tesla/vehicle/'+encodeURIComponent(v.vin)+'/data'); if(t.ok){ render(); return; } }catch{}
      }
      wk.disabled=false; wk.textContent='Still waking — tap ↻ in a minute';
    };
    const q=$('q');
    if(q){
      const chips=[...document.querySelectorAll('.chip[data-q]')];
      const secs=[...document.querySelectorAll('details.sec')];
      const count=$('speccount'); count.textContent=chips.length+' fields';
      q.oninput=()=>{
        const needle=q.value.trim().toLowerCase();
        let shown=0;
        chips.forEach(c=>{ const hit=!needle||c.dataset.q.includes(needle); c.classList.toggle('hide',!hit); if(hit)shown++; });
        secs.forEach(s=>{
          const any=[...s.querySelectorAll('.chip')].some(c=>!c.classList.contains('hide'));
          s.style.display=any?'':'none'; if(needle&&any) s.open=true;
        });
        count.textContent=needle?(shown+' matches'):(chips.length+' fields');
      };
    }
  }catch{
    page.innerHTML='<div class="empty conf">Couldn’t reach the server — <button class="gbtn sec" onclick="render()">↻ Retry</button></div>';
  }
}
render();
// stay live without hammering the car
setInterval(()=>{ if(!document.hidden) render(); },60000);

// ---- Upgrade / billing (TK-10227). Hidden unless the server says billing is
// configured (BILLING_ENABLED=1 + Stripe TEST key) — with billing off this
// fetch returns {configured:false} and the section renders NOTHING, so the
// page is pixel-identical to the pre-billing dashboard.
async function renderUpgrade(){
  const box=$('upgrade'); if(!box) return;
  try{
    const b=await (await fetch('/api/billing/status')).json();
    if(!b||!b.configured){ box.innerHTML=''; return; }
    const cur=b.tier||'free';
    const fmt=c=>'$'+(c/100).toFixed(2);
    const feat={
      free:['7-day history','1 vehicle'],
      plus:['90-day history','All your vehicles'],
      pro:['Full history','Parked &amp; phantom-drain analytics','Priority sampling'],
    };
    const plans=[{tier:'free',label:'Free',priceCentsMonthly:0}].concat(b.plans||[]);
    const cards=plans.map(p=>{
      const on=cur===p.tier;
      return '<div class="icard" style="'+(on?'border-color:var(--accent);':'')+'">'+
        '<span>'+esc(p.label)+'</span>'+
        '<b>'+fmt(p.priceCentsMonthly)+'<small style="display:inline;font-size:12px;color:var(--sub)">/mo</small></b>'+
        '<small>'+(feat[p.tier]||[]).join(' · ')+'</small>'+
        (on
          ?'<small style="color:var(--accent);font-weight:700">✓ Your plan</small>'
          :(p.tier==='free'?'':'<button class="gbtn" style="margin-top:8px" data-tier="'+esc(p.tier)+'">Upgrade</button>'))+
        '</div>';
    }).join('');
    box.innerHTML='<div class="bar" id="plans"><h2>Upgrade</h2><span class="conf">test mode · current plan: '+esc(cur)+'</span></div>'+
      '<div class="insights" style="margin-top:0">'+cards+'</div>';
    box.querySelectorAll('button[data-tier]').forEach(btn=>btn.onclick=async()=>{
      btn.disabled=true; const orig=btn.textContent; btn.textContent='Opening checkout…';
      try{
        const r=await fetch('/api/billing/checkout',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tier:btn.dataset.tier})});
        const j=await r.json().catch(()=>({}));
        if(r.ok&&j.url){ location.href=j.url; return; }
        btn.textContent=r.status===401?'Sign in on the home page first':'Checkout unavailable';
      }catch{ btn.textContent='Checkout unavailable'; }
      setTimeout(()=>{ btn.disabled=false; btn.textContent=orig; },4000);
    });
  }catch{ box.innerHTML=''; }
}
renderUpgrade();
</script>
</body>
</html>