← back to Charge And Explore
public/stats.html
334 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Tesla — Stats · 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;
}
*{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)}
header{display:flex;align-items:center;justify-content:space-between;padding:18px 24px;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:17px;color:var(--ink);text-decoration:none}
.brand .mk{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--accent),#0bb37f);display:grid;place-items:center;color:#fff;font-size:18px}
nav a{color:var(--sub);text-decoration:none;margin-left:18px;font-size:14px}
nav a:hover{color:var(--accent)}
.wrap{max-width:960px;margin:0 auto;padding:18px 24px 40px}
.bar{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;margin:26px 0 14px}
.bar h2{font-size:15px;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);margin:0}
.conf{font-size:12px;color:var(--sub)}
.hero{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px;display:flex;flex-wrap:wrap;align-items:center;gap:14px}
.hero .bat{font-size:34px;font-weight:800;font-variant-numeric:tabular-nums}
.bat.hi{color:var(--accent)} .bat.mid{color:var(--amber)} .bat.lo{color:#b23b3b}
.hero .vname{font-size:18px;font-weight:800}
.tstats{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.tstats .tchip{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px}
.tstats .tchip span{display:block;font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.05em}
.tstats .tchip b{font-size:16px;font-variant-numeric:tabular-nums}
.tstats .tchip.dim{opacity:.55}
.tgsel{display:inline-flex;gap:6px}
.tg-chip{border:1px solid var(--line);background:#fff;color:var(--sub);border-radius:999px;padding:4px 12px;font-size:12.5px;font-weight:600;cursor:pointer}
.tg-chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.tgcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px 14px;margin-top:8px}
.glabel{font-size:12px;color:var(--sub);text-transform:uppercase;letter-spacing:.06em;margin:2px 0 4px}
.gbtn{display:inline-block;padding:9px 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)}
.empty{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:26px;text-align:center}
footer{border-top:1px solid var(--line);background:#fff;color:var(--sub);font-size:12.5px;padding:22px 24px;text-align:center;margin-top:30px}
footer a{color:var(--sub)}
@media(max-width:640px){ .bar{flex-direction:column;align-items:stretch} nav a{margin-left:12px} }
</style>
</head>
<body>
<header>
<a class="brand" href="/"><span class="mk">⚡</span>Charge & Explore</a>
<nav>
<a href="/dashboard">🚗 Dashboard</a>
<a href="/">← Back to the app</a>
</nav>
</header>
<div class="wrap">
<div class="bar" style="margin-top:8px">
<h2>My Tesla — full stats</h2>
<span class="conf" id="asof"></span>
</div>
<div id="page"><div class="empty conf" style="text-transform:none;letter-spacing:0">Loading your car…</div></div>
</div>
<footer>
Independent product — not affiliated with, endorsed by, or sponsored by Tesla, Inc.
· <a href="/privacy.html">Privacy</a> · <a href="/support.html">Support</a>
</footer>
<script>
// ---- Dedicated Tesla stats page (Dust2026: "display all stats in its own page").
// Shows EVERY stat field — charging-only fields render dimmed with "—" when the
// car isn't charging, so the full picture is always on screen. Same remembered
// graph window (ce-tgHours + /api/me/settings) as the homepage cards.
let __signedIn=false, __prefsTimer=null, __prefsPending={};
function savePref(k,v){
try{ localStorage.setItem('ce-'+k, v) }catch{}
if(!__signedIn) return;
__prefsPending[k]=v; clearTimeout(__prefsTimer);
__prefsTimer=setTimeout(()=>{ const b=__prefsPending; __prefsPending={};
fetch('/api/me/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)}).catch(()=>{});
},600);
}
let __batteryKWh=null; // saved EV's usable capacity → ΔSOC energy fallback server-side
fetch('/api/me').then(r=>r.ok?r.json():null).then(d=>{
if(d&&d.user) __signedIn=true;
if(d&&d.vehicle&&d.vehicle.usableBatteryKWh) __batteryKWh=d.vehicle.usableBatteryKWh;
}).catch(()=>{});
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'; }
const bc=p=>p==null?'':(p>=60?'hi':p>=25?'mid':'lo');
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
// ---- Multi-car picker (TK-10227). Shown ONLY when the account has >1 vehicle;
// the choice persists in localStorage (ce-vin) + signed-in server settings and
// rides every API call as ?vin=. One car → no picker, exactly the old page.
function selVin(){ try{ return localStorage.getItem('ce-vin')||'' }catch{ return '' } }
function carPickerHtml(t){
const list=t.vehicles||[];
if(list.length<2) return '';
const cur=(t.vehicle&&t.vehicle.vin)||selVin();
return '<div class="bar" style="margin:0 0 10px"><span class="tgsel" id="carsel">'+
list.map(c=>'<button class="tg-chip'+(c.vin===cur?' on':'')+'" data-vin="'+esc(c.vin)+'">🚗 '+esc(c.name||c.vin.slice(-6))+'</button>').join('')+
'</span></div>';
}
function wireCarPicker(){
document.querySelectorAll('#carsel .tg-chip').forEach(b=>b.onclick=()=>{
savePref('vin', b.dataset.vin);
render();
});
}
// Every stat, always visible. [label, value, chargingOnly]
function allStatsHtml(v){
const s=v.stats||{}, charging=v.chargingState==='Charging';
const rows=[
['Battery', v.soc!=null?v.soc+'%':null, false],
['Usable', s.usableSoc!=null?s.usableSoc+'%':null, false],
['Range', v.rangeMiles!=null?v.rangeMiles+' mi':null, false],
['Est. range', s.estRangeMiles!=null?s.estRangeMiles+' mi':null, false],
['Charge limit', s.chargeLimit!=null?s.chargeLimit+'%':null, false],
['Charging state', v.chargingState||null, false],
['Charger power', s.chargerPowerKw!=null?s.chargerPowerKw+' kW':null, true],
['Charge rate', s.chargeRateMph!=null?'+'+s.chargeRateMph+' mi/hr':null, true],
['Time to limit', fmtMins(s.minutesToFull), true],
['Energy added', s.energyAddedKWh!=null?s.energyAddedKWh+' kWh':null, true],
['Inside', fmtTempF(s.insideTempC), false],
['Outside', fmtTempF(s.outsideTempC), false],
['Odometer', s.odometerMiles!=null?s.odometerMiles.toLocaleString()+' mi':null, false],
['Locked', s.locked==null?null:(s.locked?'🔒 Yes':'🔓 No'), false],
['Sentry', s.sentry==null?null:(s.sentry?'👁 On':'Off'), false],
['Software', s.version||null, false],
];
return rows.map(([l,val,co])=>{
const dim=co&&!charging;
return `<div class="tchip${dim?' dim':''}"><span>${l}</span><b>${(dim&&val==null)?'—':(val==null?'—':val)}</b></div>`;
}).join('');
}
// Dependency-free SVG line chart with charging-period shading.
function levelChart(pts, key, o){
const vals=pts.filter(p=>p[key]!=null);
if(vals.length<2) return '<div class="conf">Not enough history yet — points are recorded on each read; check back soon.</div>';
const W=600,H=150,PL=36,PR=10,PT=12,PB=20;
const t0=vals[0].t, t1=vals[vals.length-1].t;
const ymax=o.max||Math.ceil(Math.max(...vals.map(p=>p[key]))*1.1);
const x=t=>PL+(W-PL-PR)*((t-t0)/Math.max(1,t1-t0));
const y=v=>PT+(H-PT-PB)*(1-v/ymax);
// shade contiguous charging runs (⚡) behind the line
let bands='', runStart=null;
for(let i=0;i<vals.length;i++){
const c=!!vals[i].charging;
if(c&&runStart==null) runStart=vals[i].t;
if((!c||i===vals.length-1)&&runStart!=null){
const end=c?vals[i].t:vals[Math.max(0,i-1)].t;
bands+=`<rect x="${x(runStart).toFixed(1)}" y="${PT}" width="${Math.max(2,x(end)-x(runStart)).toFixed(1)}" height="${H-PT-PB}" fill="#0a7d5a" fill-opacity=".07"/>`;
runStart=null;
}
}
const line=vals.map((p,i)=>(i?'L':'M')+x(p.t).toFixed(1)+' '+y(p[key]).toFixed(1)).join(' ');
const area=line+` L${x(t1).toFixed(1)} ${y(0).toFixed(1)} L${x(t0).toFixed(1)} ${y(0).toFixed(1)} Z`;
const gl=(o.grid&&o.grid.length?o.grid:[Math.round(ymax/3),Math.round(2*ymax/3)]).filter(g=>g>0&&g<ymax);
const grid=gl.map(g=>`<line x1="${PL}" x2="${W-PR}" y1="${y(g).toFixed(1)}" y2="${y(g).toFixed(1)}" stroke="#e6ebf0"/><text x="4" y="${(y(g)+4).toFixed(1)}" font-size="10" fill="#5b6b7a">${g}${o.unit}</text>`).join('');
const last=vals[vals.length-1];
const fmt=d=>new Date(d).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric'});
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${o.label} over time" style="width:100%;display:block">
${bands}${grid}
<path d="${area}" fill="${o.color}" fill-opacity=".1"/>
<path d="${line}" fill="none" stroke="${o.color}" stroke-width="2"/>
<circle cx="${x(last.t).toFixed(1)}" cy="${y(last[key]).toFixed(1)}" r="3.5" fill="${o.color}"/>
<text x="${W-PR}" y="${Math.max(12,y(last[key])-8).toFixed(1)}" text-anchor="end" font-size="12" font-weight="700" fill="${o.color}">${last[key]}${o.unit}</text>
<text x="${PL}" y="${H-4}" font-size="10" fill="#5b6b7a">${fmt(t0)}</text>
<text x="${W-PR}" y="${H-4}" text-anchor="end" font-size="10" fill="#5b6b7a">${fmt(t1)}</text>
</svg>`;
}
function renderGraphs(vin, hours){
const el=document.getElementById('tgraphs'); if(!el) return;
el.innerHTML='<div class="conf">Loading history…</div>';
fetch('/api/tesla/history?vin='+encodeURIComponent(vin||'')+'&hours='+hours).then(r=>r.json()).then(d=>{
const pts=d.points||[];
el.innerHTML='<div class="tgcard"><div class="glabel">🔋 Battery level <span style="text-transform:none;letter-spacing:0">· shaded = charging</span></div>'+
levelChart(pts,'soc',{max:100,unit:'%',color:'#0a7d5a',grid:[20,50,80],label:'Battery level'})+
'</div><div class="tgcard"><div class="glabel">🛣 Range</div>'+
levelChart(pts,'range',{unit:' mi',color:'#4285F4',label:'Range'})+
'</div>';
}).catch(()=>{ el.innerHTML='<div class="conf">History unavailable right now.</div>'; });
}
// ---- Charging sessions (Dust2026: per-session kW curve + $/kWh estimate).
// Cost = energy × the user's editable rate; "est." marks ΔSOC-derived energy.
let __sessions=[];
function kwhRate(){ try{ const r=parseFloat(localStorage.getItem('ce-kwhRate')); return r>0?r:0.30 }catch{ return 0.30 } }
function fmtSessTime(s){
const d1=new Date(s.startT), d2=new Date(s.endT);
const day=d1.toLocaleDateString(undefined,{month:'short',day:'numeric'});
const t=x=>x.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'});
return day+' · '+t(d1)+'–'+t(d2);
}
function sessionCards(){
const rate=kwhRate();
if(!__sessions.length) return '<div class="conf" style="padding:6px 2px">No charging sessions in the last 30 days yet — sessions appear once the car charges while connected.</div>';
return __sessions.map(s=>{
const est=s.energySource==='soc-estimate';
const cost=s.energyKWh!=null?'$'+(s.energyKWh*rate).toFixed(2):null;
// .tchip span CSS uppercases/blocks label spans — notes opt back out inline
const note=t=>' <span class="conf" style="display:inline;text-transform:none">'+t+'</span>';
const chips=[
['Battery', s.socFrom+'% → '+s.socTo+'%'],
['Duration', fmtMins(Math.max(1,s.durationMin))],
['Energy', s.energyKWh!=null?s.energyKWh+' kWh'+(est?note('est.'):''):'—'],
['Peak', s.peakKw!=null?s.peakKw+' kW':'—'],
['Avg', s.avgKw!=null?s.avgKw+' kW':'—'],
['Est. cost', cost?cost+note('@ $'+rate.toFixed(2)+'/kWh'):'—'],
].map(([l,v])=>`<div class="tchip"><span>${l}</span><b>${v}</b></div>`).join('');
const chart=s.curve.length>=2
? levelChart(s.curve.map(c=>({t:c.t,kw:c.kw,charging:true})),'kw',{unit:' kW',color:'#b8860b',label:'Charging power'})
: '<div class="conf">Not enough samples for a power curve — longer sessions chart automatically.</div>';
return '<div class="tgcard"><div class="glabel">⚡ '+fmtSessTime(s)+'</div>'+
'<div class="tstats" style="margin:6px 0 10px">'+chips+'</div>'+chart+'</div>';
}).join('');
}
function renderSessions(vin){
const el=document.getElementById('tsessions'); if(!el) return;
el.innerHTML='<div class="conf">Loading sessions…</div>';
const bk=__batteryKWh?'&batteryKWh='+__batteryKWh:'';
fetch('/api/tesla/sessions?vin='+encodeURIComponent(vin||'')+'&days=30'+bk).then(r=>r.json()).then(d=>{
__sessions=d.sessions||[];
el.innerHTML=sessionCards();
}).catch(()=>{ el.innerHTML='<div class="conf">Sessions unavailable right now.</div>'; });
}
// ---- Parked stats (Dust2026: "stats when parked"). Phantom drain while the
// car sat still — periods are odometer-proven (the car didn't move), so a SOC
// drop here is genuine parked drain, never a drive.
function fmtHours(h){ return h>=1 ? (Math.round(h*10)/10)+' h' : Math.round(h*60)+' min'; }
function parkedCards(d){
const s=d.summary||{}, periods=d.periods||[];
if(!periods.length) return '<div class="conf" style="padding:6px 2px">No parked periods yet — parked stats build up from samples recorded while the car sits (odometer-verified), so this fills in over the next day or two of normal parking.</div>';
const note=t=>' <span class="conf" style="display:inline;text-transform:none">'+t+'</span>';
const sum=[
['Drain rate', s.avgDrainPctPerDay!=null?s.avgDrainPctPerDay+'%/day':'—'],
['Parked time', fmtHours(s.totalHours||0)],
['Battery used', (s.totalSocDrop||0)+'%'],
['Range lost', s.totalRangeLostMiles!=null?s.totalRangeLostMiles+' mi':'—'],
['Sentry time', fmtHours(s.sentryHours||0)],
].map(([l,v])=>`<div class="tchip"><span>${l}</span><b>${v}</b></div>`).join('');
const cards=periods.slice(0,10).map(p=>{
const chips=[
['Battery', p.socFrom+'% → '+p.socTo+'%'],
['Duration', fmtHours(p.hours)],
['Drain', p.drainPctPerDay!=null?p.drainPctPerDay+'%/day':(p.socDrop+'%'+note('too short for a rate'))],
['Range lost', p.rangeLostMiles!=null?p.rangeLostMiles+' mi':'—'],
['Sentry', p.sentryOn?'on':'off'],
].map(([l,v])=>`<div class="tchip"><span>${l}</span><b>${v}</b></div>`).join('');
return '<div class="tgcard"><div class="glabel">🅿️ '+fmtSessTime(p)+'</div>'+
'<div class="tstats" style="margin:6px 0 2px">'+chips+'</div></div>';
}).join('');
const more=periods.length>10?'<div class="conf" style="padding:4px 2px">Showing the 10 most recent of '+periods.length+' parked periods.</div>':'';
return '<div class="tgcard"><div class="glabel">Σ Parked totals · last 30 days</div>'+
'<div class="tstats" style="margin:6px 0 2px">'+sum+'</div></div>'+cards+more;
}
function renderParked(vin){
const el=document.getElementById('tparked'); if(!el) return;
el.innerHTML='<div class="conf">Loading parked stats…</div>';
fetch('/api/tesla/parked?vin='+encodeURIComponent(vin||'')+'&days=30').then(r=>r.json()).then(d=>{
el.innerHTML=parkedCards(d);
}).catch(()=>{ el.innerHTML='<div class="conf">Parked stats unavailable right now.</div>'; });
}
function render(){
const page=document.getElementById('page');
const vin=selVin();
fetch('/api/tesla/summary'+(vin?'?vin='+encodeURIComponent(vin):'')).then(r=>r.json()).then(t=>{
if(!t.connected){
page.innerHTML='<div class="empty"><p style="margin-top:0">Connect your Tesla to see live battery, range, charging, climate, and history graphs here.</p>'+
'<a class="gbtn" href="/auth/tesla/login">Connect Tesla</a> <a class="gbtn sec" href="/">Back to the app</a></div>';
return;
}
const v=t.vehicle;
if(!v||v.soc==null){
page.innerHTML=carPickerHtml(t)+'<div class="empty conf" style="text-transform:none;letter-spacing:0">Connected — car data unavailable right now. <button class="gbtn sec" onclick="render()">↻ Retry</button> <a class="gbtn" href="/auth/tesla/login">Re-connect Tesla</a></div>';
wireCarPicker();
return;
}
const when=v.asleep&&v.cachedAt?new Date(v.cachedAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):null;
document.getElementById('asof').textContent = v.asleep ? ('😴 car asleep — last known'+(when?' · '+when:'')) : 'live from your car';
const tgHours=(()=>{ try{ return Number(localStorage.getItem('ce-tgHours'))||24 }catch{ return 24 } })();
page.innerHTML=
carPickerHtml(t)+
'<div class="hero">'+
'<span class="bat '+bc(v.soc)+'">'+v.soc+'%</span>'+
'<div><div class="vname">'+(v.name||'My Tesla')+'</div>'+
'<span class="conf">'+(v.chargingState==='Charging'?'⚡ charging now':(v.chargingState||''))+
(v.rangeMiles!=null?' · ~'+v.rangeMiles+' mi range':'')+'</span></div>'+
'<span style="flex:1"></span><button class="gbtn sec" id="refresh">↻ Refresh</button>'+
'</div>'+
'<div class="bar"><h2>All stats</h2><span class="conf">every field your car reports — charging fields light up while plugged in</span></div>'+
'<div class="tstats">'+allStatsHtml(v)+'</div>'+
'<div class="bar"><h2>Levels</h2><span class="tgsel">'+
[[24,'24h'],[168,'7d'],[720,'30d']].map(([h,l])=>'<button class="tg-chip'+(h===tgHours?' on':'')+'" data-h="'+h+'">'+l+'</button>').join('')+
'</span></div><div id="tgraphs"></div>'+
'<div class="bar"><h2>Charging sessions</h2><span class="conf">last 30 days · rate $'+
'<input id="kwhRate" type="number" min="0.01" step="0.01" value="'+kwhRate().toFixed(2)+'"'+
' style="width:64px;font:inherit;border:1px solid var(--line);border-radius:6px;padding:2px 6px">/kWh</span></div>'+
'<div id="tsessions"></div>'+
'<div class="bar"><h2>Parked</h2><span class="conf">last 30 days · phantom drain while the car sat still</span></div>'+
'<div id="tparked"></div>';
renderGraphs(v.vin, tgHours);
renderSessions(v.vin);
renderParked(v.vin);
document.getElementById('kwhRate').onchange=e=>{
const r=parseFloat(e.target.value);
if(r>0){ savePref('kwhRate', String(r)); const el=document.getElementById('tsessions'); if(el) el.innerHTML=sessionCards(); }
};
document.querySelectorAll('.tg-chip').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.tg-chip').forEach(x=>x.classList.toggle('on',x===b));
savePref('tgHours', b.dataset.h);
renderGraphs(v.vin, +b.dataset.h);
});
document.getElementById('refresh').onclick=render;
wireCarPicker();
}).catch(()=>{
page.innerHTML='<div class="empty conf" style="text-transform:none;letter-spacing:0">Couldn’t reach the server — <button class="gbtn sec" onclick="render()">↻ Retry</button></div>';
});
}
render();
// Keep the page live without hammering the car: refresh every 60s while visible.
setInterval(()=>{ if(!document.hidden) render(); }, 60000);
</script>
</body>
</html>