← 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 &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;
  }
  *{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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>