← 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 & 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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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 & 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>