/* stats-v2.jsx — statistik och ekonomi, kollegans design mot riktig data. ──────────────────────────────────────────────────────────────────────── Porterat ur GravioApp.jsx StatistikView. Formen är behållen: staplar med egen el och import uppåt, export nedåt, produktionslinjen över, periodväxlare Dag/Vecka/Månad/År och pilnavigering bakåt i tiden. HELA buildStats är struken. Prototypen hittade på varje siffra ur sinusfunktioner och slumptal, och ekonomiblocket räknade "Batteriet tillför 340 kr" med koefficienterna 0,45 / 0,35 / 0,9 / 0,12 / 0,5. Ingen av dem har någon motsvarighet i backend. Två källor, båda riktiga: Dag /api/history consumption[].quarters[] — uppmätt W-medel per kvart, summerat per timme. Sol, förbrukning, import och export. Period /api/economics/comparison days[] — ett dygn per post med import_kwh, export_kwh, solar_kwh, load_kwh och tre kostnader: actual (HEMS-styrt), passive (samma batteri utan HEMS) och no_battery. Ekonomiraden läser `savings_vs_passive_kr`. Procenttalet visas bara när passivkostnaden är stor nog att en procent ska betyda något — annars säger den mer om nämnaren än om besparingen. Dygn utan data kommer som {date, missing:true} och ritas grå. Aldrig fel, aldrig noll. */ const SV_MONO = { fontFamily: '"Geist Mono", ui-monospace, monospace' }; const SV_MONTH = ['januari','februari','mars','april','maj','juni','juli','augusti','september','oktober','november','december']; const SV_MONTH_S = ['J','F','M','A','M','J','J','A','S','O','N','D']; const SV_DAY_S = ['Mån','Tis','Ons','Tor','Fre','Lör','Sön']; // Historiken börjar här och växer ett dygn per natt — ingen backfill (beslut // 2026-08-16). Att navigera längre bak ger bara grå staplar, så pilen stoppar. const SV_DATA_START = '2026-08-15'; const SV_MIN_YEAR = 2026; const svN = (n, d=1) => n == null ? '—' : n.toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); /* Tecknet sätts efter AVRUNDNINGEN, inte råvärdet — annars visas "−0,00 kr" för ett tal som är negativt men avrundas till noll (setts 2026-08-16 när dagens dygn nyss börjat). Ingen skillnad kan ändå inte ha ett tecken. */ /* kWh visas ALLTID med en decimal, samma som totalraden. Tröskeln som fanns här gav 34,6 i raden och "35" i kortet — samma storhet, två tal. */ const svKwh = (n) => n == null ? '—' : svN(n, 1); const svKr = (n) => { if (n == null) return '—'; const a = Math.abs(n); return (a >= 0.005 && n < 0 ? '−' : '') + svN(a, 2) + ' kr'; }; const svISO = (dt) => dt.getFullYear()+'-'+String(dt.getMonth()+1).padStart(2,'0')+'-'+String(dt.getDate()).padStart(2,'0'); function svIsoWeek(dt){ const d = new Date(Date.UTC(dt.getFullYear(), dt.getMonth(), dt.getDate())); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const y0 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - y0)/86400000) + 1)/7); } // Måndag som veckostart (sv-SE). const svMonday = (dt) => { const d = new Date(dt); d.setHours(0,0,0,0); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d; }; /* ---- Dagens timmar ur /api/history ------------------------------------ quarters[] är W-medel; kWh = W/1000 × 0,25. Pågående kvart är delvis fylld (w av 900 sekunder) och skalas därefter, annars ser den innestående timmen ut att ha halkat efter. grid negativ = export. */ function svHoursFromHistory(history, dateStr){ const hours = Array.from({length:24}, () => null); for (const row of (history?.consumption || [])){ if (row.date !== dateStr) continue; const h = row.hour; if (!(h >= 0 && h < 24)) continue; const qs = Array.isArray(row.quarters) ? row.quarters.filter(Boolean) : null; let prod = 0, cons = 0, imp = 0, exp = 0, any = false; if (qs && qs.length){ for (const q of qs){ const f = 0.25 * (q.w != null && q.w < 890 ? q.w/900 : 1); prod += ((q.sol || 0)/1000)*f; cons += ((q.home || 0)/1000)*f; const g = (q.grid || 0)/1000; imp += Math.max(0, g)*f; exp += Math.max(0, -g)*f; any = true; } } else if (row.home_consumption != null){ prod = ((row.solar_power || 0)/1000); cons = ((row.home_consumption || 0)/1000); const g = (row.grid_power || 0)/1000; imp = Math.max(0, g); exp = Math.max(0, -g); any = true; } if (any) hours[h] = { prod, cons, imp, exp, self: Math.max(0, cons - imp), hourly: !qs }; } return hours; } /* ---- Dygn ur /api/economics/comparison -------------------------------- */ function svDayRow(d){ if (!d || d.missing) return { date: d?.date, missing: true }; const prod = d.solar_kwh ?? null; const imp = d.actual?.import_kwh ?? null; const exp = d.actual?.export_kwh ?? null; return { date: d.date, partial: d.partial || undefined, prod, cons: d.load_kwh ?? null, imp, exp, self: (d.load_kwh != null && imp != null) ? Math.max(0, d.load_kwh - imp) : null, actual: d.actual?.cost_kr ?? null, // v2-fälten (live 16/8, rev 01378). Dygn räknade före 16/8 har null — // saknad nyckel betyder "inte räknat", aldrig noll. nsnb: d.no_solar_no_battery?.cost_kr ?? null, savNsnb: d.savings_vs_no_solar_no_battery_kr ?? null, chargeKwh: d.actual?.charge_kwh ?? null, passive: d.passive?.cost_kr ?? null, noBat: d.no_battery?.cost_kr ?? null, savPassive: d.savings_vs_passive_kr ?? null, /* Carry-justerad besparing. Den råa visar fel TECKEN ungefär var tredje dygn: energi som HEMS lagrar bokförs som förlust den dagen och vinst nästa. Äldre dygn har null — då gäller den råa, aldrig noll. */ savPassiveAdj: d.savings_vs_passive_adj_kr ?? null, carryOut: d.carry_out_kr ?? null, carryIn: d.carry_in_kr ?? null, socDeltaEnd: d.soc_delta_end_kwh ?? null, socEndPct: d.actual?.soc_end_pct ?? null, savNoBat: d.savings_vs_no_battery_kr ?? null, hasAi: d.has_ai_battery || undefined, coverage: d.coverage_quarters ?? null, }; } function StatsV2({ history, comparison, savings, today, onRange, settings }){ const [period, setPeriod] = React.useState('dag'); const [offset, setOffset] = React.useState(0); const [selBar, setSelBar] = React.useState(null); React.useEffect(() => { setOffset(0); setSelBar(null); }, [period]); React.useEffect(() => { setSelBar(null); }, [offset]); const base = today || (window.hemsDate ? window.hemsDate() : new Date()); /* Navigerat datum och intervall. Systemstart 2026 — inget före. */ const nav = React.useMemo(() => { if (period === 'dag'){ const d = new Date(base); d.setDate(d.getDate() + offset); return { from: svISO(d), to: svISO(d), date: d }; } if (period === 'vecka'){ const m = svMonday(base); m.setDate(m.getDate() + offset*7); const e = new Date(m); e.setDate(e.getDate() + 6); return { from: svISO(m), to: svISO(e), date: m }; } if (period === 'manad'){ const s = new Date(base.getFullYear(), base.getMonth() + offset, 1); const e = new Date(s.getFullYear(), s.getMonth() + 1, 0); return { from: svISO(s), to: svISO(e), date: s }; } const y = base.getFullYear() + offset; return { from: y+'-01-01', to: y+'-12-31', date: new Date(y, 0, 1) }; }, [period, offset, base]); /* Föräldern hämtar intervallet. Dagens dygn kräver today=1. Vi frågade tidigare ett dygn EXTRA bakåt i dagsvyn: ett ensamt dygn (from === to) kom tillbaka som {missing:true} medan samma dygn i ett tvådygnsspann kom som {partial:true} — ett tomt live-dygn från strax efter midnatt låstes i cachen under enkeldygnsnyckeln. Backend rättade det 2026-08-16 (hems-staging-01374-leg): from=to=idag&today=1 ger partial så fort första kvarten finns, today=1-svar cachas 15 min och det tomma svaret cachas inte. Workarounden är därför borta — vi hämtar exakt visat spann. Filtret nedan står kvar som skydd mot dygn utanför spannet. */ const needsToday = nav.to >= svISO(base); React.useEffect(() => { if (onRange) onRange(nav.from, nav.to, needsToday); }, [nav.from, nav.to, needsToday, onRange]); // Bara dygn inom det VISADE spannet får räknas, oavsett vad vi hämtade. const inRange = React.useCallback( (d) => !!d?.date && d.date >= nav.from && d.date <= nav.to, [nav.from, nav.to]); const maxBack = React.useMemo(() => { const min = new Date(SV_DATA_START + 'T00:00:00'); const b0 = new Date(base); b0.setHours(0,0,0,0); if (period === 'dag') return Math.max(0, Math.round((b0 - min)/86400000)); if (period === 'vecka') return Math.max(0, Math.round((svMonday(b0) - svMonday(min))/(7*86400000))); if (period === 'manad') return Math.max(0, (b0.getFullYear()-min.getFullYear())*12 + (b0.getMonth()-min.getMonth())); return Math.max(0, b0.getFullYear() - min.getFullYear()); }, [period, base]); /* Staplarna. Dag läser timmar ur history, övriga dygn ur comparison. */ const chart = React.useMemo(() => { if (period === 'dag'){ const hrs = svHoursFromHistory(history, nav.from); return { labels: hrs.map((_, h) => String(h).padStart(2,'0')), rows: hrs.map(h => h ? { ...h, missing:false } : { missing:true }), unit: 'timme', }; } const byDate = {}; for (const d of (comparison?.days || [])){ if (!inRange(d)) continue; const r = svDayRow(d); if (r.date) byDate[r.date] = r; } const out = [], labels = []; if (period === 'ar'){ // Månadssummor ur dygnen. En månad utan ett enda dygn är saknad. for (let m = 0; m < 12; m++){ const pre = nav.date.getFullYear()+'-'+String(m+1).padStart(2,'0'); const days = Object.keys(byDate).filter(k => k.startsWith(pre)).map(k => byDate[k]); const real = days.filter(x => !x.missing); labels.push(SV_MONTH_S[m]); out.push(real.length ? { missing:false, prod: real.reduce((a,x)=>a+(x.prod||0),0), cons: real.reduce((a,x)=>a+(x.cons||0),0), imp: real.reduce((a,x)=>a+(x.imp||0),0), exp: real.reduce((a,x)=>a+(x.exp||0),0), self: real.reduce((a,x)=>a+(x.self||0),0), days: real.length, } : { missing:true }); } return { labels, rows: out, unit:'månad' }; } const start = new Date(nav.from + 'T00:00:00'); const end = new Date(nav.to + 'T00:00:00'); for (let d = new Date(start); d <= end; d.setDate(d.getDate()+1)){ const key = svISO(d); labels.push(period === 'vecka' ? SV_DAY_S[(d.getDay()+6)%7] : String(d.getDate())); out.push(byDate[key] || { missing:true, date:key }); } return { labels, rows: out, unit:'dygn' }; }, [period, nav, history, comparison, inRange]); /* Ekonomi. Summeras ur dygnen — backendens totals gäller det efterfrågade spannet, och vi visar samma spann, men vi summerar själva så att År (månadsstaplar ur samma dygn) alltid stämmer mot raden ovanför. */ const eco = React.useMemo(() => { const days = (comparison?.days || []).filter(inRange).map(svDayRow).filter(x => !x.missing); if (!days.length) return null; const sum = (f) => days.reduce((a,x) => a + (f(x) || 0), 0); const actual = sum(x => x.actual), passive = sum(x => x.passive), noBat = sum(x => x.noBat); const r2 = (x) => Math.round((x || 0)*100)/100; /* v2 (16/8): dygn utan de nya nycklarna räknas inte in — annars vore ett null-dygn samma sak som ett nolldygn. nsnbDays säger hur många som bär dem, så täckningstexten kan säga sanningen. */ const v2 = days.filter(x => x.nsnb != null); const adj = days.filter(x => x.savPassiveAdj != null); const sumV2 = (f) => v2.reduce((a,x) => a + (f(x) || 0), 0); return { days: days.length, requested: (comparison?.days || []).filter(inRange).length, actual, passive, noBat, // Härledd ur de avrundade stapelvärdena, aldrig summerad för sig. savPassive: r2(passive) - r2(actual), /* Lagerjusterat: bara när samtliga dygn i perioden bär fältet, annars blandas två mätmetoder i en summa. Carry = ut − in, och för ett enskilt dygn följer även slutdifferensen i kWh med till förklaringsraden. */ savPassiveAdj: adj.length === days.length && adj.length ? r2(adj.reduce((a,x) => a + (x.savPassiveAdj || 0), 0)) : null, /* Signal, inte visat tal: finns lagerposterna alls för hela perioden? */ carry: adj.length === days.length && adj.length && days.every(x => x.carryOut != null || x.carryIn != null) ? true : null, socDeltaEnd: days.length === 1 ? days[0].socDeltaEnd : null, savNoBat: r2(noBat) - r2(actual), nsnbDays: v2.length, // Delvis täckning duger inte: "utan sol och batteri" över ett dygn minus // utfallet över två är ingen besparing, bara en differens mellan två // spann. Allt-eller-inget, och fotnoten säger varför. nsnb: v2.length === days.length && v2.length ? sumV2(x => x.nsnb) : null, // Solcellernas värde = skillnaden mellan "inget alls" och "sol utan // batteri". Batteriets = skillnaden mellan "sol utan batteri" och // utfallet. De två summerar till hela besparingen, per backendbeskedet. solVarde: v2.length === days.length && v2.length ? r2(sumV2(x => x.nsnb)) - r2(sumV2(x => x.noBat)) : null, batVarde: v2.length === days.length && v2.length ? r2(sumV2(x => x.noBat)) - r2(sumV2(x => x.actual)) : null, chargeKwh: v2.length === days.length && v2.length ? sumV2(x => x.chargeKwh) : null, partial: days.some(x => x.partial), hasAi: days.some(x => x.hasAi), }; }, [comparison, inRange]); const navLbl = (() => { if (period === 'dag'){ if (offset === 0) return 'Idag'; if (offset === -1) return 'Igår'; return nav.date.toLocaleDateString('sv-SE', { weekday:'short', day:'numeric', month:'short' }); } if (period === 'vecka') return (offset === 0 ? 'Denna vecka · ' : '') + 'v.' + svIsoWeek(nav.date) + (offset === 0 ? '' : ' ' + nav.date.getFullYear()); if (period === 'manad') return SV_MONTH[nav.date.getMonth()] + ' ' + nav.date.getFullYear(); return String(nav.date.getFullYear()); })(); /* Diagramgeometri. Egen el + import uppåt = förbrukningen, export nedåt. Uppåtstapeln är alltså en riktig uppdelning av förbrukningen; sol som laddade batteriet ligger i produktionslinjen, inte i stapeln. */ /* Rullande fönster ur backend (18/8). Dygnsvärdet "vs passivt" visar fel tecken ungefär var tredje dag, medan 7 och 30 dagar visar rätt bild 96 % av gångerna. Därför sitter husets huvudsiffra HÄR och inte i den navigerade perioden. Fönstren slutar igår — dagens dygn ingår aldrig, eftersom vinsten till stor del bokförs på kvällen. Saknas nyckeln (före deploy) visas ingenting; ett halvt fönster vore värre än inget. */ const win = React.useMemo(() => { const w = comparison?.windows; if (!w) return null; const pick = (k) => { const x = w[k]; if (!x) return null; return { days: x.days_with_data ?? null, total: x.savings_vs_no_solar_no_battery_kr ?? null, bat: x.savings_vs_no_battery_kr ?? null, // adj är sanningen; den råa duger som fallback och märks ut. hems: x.savings_vs_passive_adj_kr ?? x.savings_vs_passive_kr ?? null, hemsAdj: x.savings_vs_passive_adj_kr != null, }; }; const d30 = pick('30d'), d7 = pick('7d'); return (d30 || d7) ? { d30, d7 } : null; }, [comparison]); const n = chart.rows.length; const W = 640, H = 300, padT = 30, padB = 28; const upMax = Math.max(0.001, ...chart.rows.map(r => r.missing ? 0 : (r.self||0)+(r.imp||0)), ...chart.rows.map(r => r.missing ? 0 : (r.prod||0))); const dnMax = Math.max(0.001, ...chart.rows.map(r => r.missing ? 0 : (r.exp||0))); const kY = (H - padT - padB)/(upMax + dnMax); const y0 = padT + upMax*kY; const slot = W/n; const sel = selBar !== null && selBar < n ? selBar : null; const selRow = sel !== null ? chart.rows[sel] : null; const chartRef = React.useRef(null); const dragging = React.useRef(false); const barFrom = (e) => { const el = chartRef.current; if (!el) return null; const r = el.getBoundingClientRect(); const i = Math.floor(((e.clientX - r.left)/r.width)*n); return Math.min(n-1, Math.max(0, i)); }; const down = (e) => { dragging.current = true; e.currentTarget.setPointerCapture?.(e.pointerId); const i = barFrom(e); if (i !== null) setSelBar(i); }; const move = (e) => { if (dragging.current){ const i = barFrom(e); if (i !== null) setSelBar(i); } }; const up = () => { dragging.current = false; }; const showTick = (i) => period === 'dag' ? i % 6 === 0 : period === 'manad' ? i % 7 === 0 : true; const prodPath = React.useMemo(() => { const pts = chart.rows.map((r, i) => r.missing ? null : [(i+0.5)*slot, y0 - (r.prod||0)*kY]); let dPath = '', open = false; pts.forEach(p => { if (!p){ open = false; return; } dPath += (open ? 'L' : 'M') + p[0].toFixed(1) + ',' + p[1].toFixed(1); open = true; }); return dPath; }, [chart, slot, kY, y0]); const totals = React.useMemo(() => { const real = chart.rows.filter(r => !r.missing); const sum = (k) => real.reduce((a,r) => a + (r[k]||0), 0); /* "Egen el" härleds ur de VISADE talen, inte ur en egen summa. Rutan påstår att egen el + köpt = förbrukning, och två oberoende avrundningar kan dra åt samma håll (16,2 + 0,3 mot 16,4). Samma regel som timraden i Avancerat och besparingstalen: det som ska gå ihop i rutan räknas ur rutans egna tal. */ const r1 = (x) => Math.round((x || 0)*10)/10; const cons = sum('cons'), imp = sum('imp'); return { prod:sum('prod'), cons, imp, exp:sum('exp'), self: Math.max(0, r1(cons) - r1(imp)), n:real.length }; }, [chart]); /* Täckning i klartext. Backend räknar redan totals bara över dygn med data, så siffran här beskriver samma urval. Historiken växer framåt, inte bakåt — därför "sedan 15 aug" och inte "fylls på bakåt". */ /* Hans nyckeltal (StatistikView 1758–1810). FEM går på dagens kontrakt, och de räknas ur samma dygn som staplarna så raderna alltid stämmer mot varandra. Tre gör det INTE och byggs inte förrän fälten finns (backendbesked 16/8) — att härleda dem på annat sätt vore att hitta på: • "utan sol och batteri" → no_solar_no_battery.cost_kr (beställt). Backendens no_battery är utan BATTERI men med solen kvar; skillnaden är hela solproduktionens värde. Etiketten säger därför "utan batteri". • "vad solcellerna ger" och "vad batteriet tillför" → samma doc. • cykler → actual.charge_kwh / kapacitet (beställt). Årväljaren och jämförelsen mot föregående år är strukna: historiken börjar 15/8 2026, så det finns inget föregående år att jämföra med. */ /* Kapacitet till cyklerna: summan av de HEMS-styrda batterierna. Ett AI-styrt batteri räknas inte — dess laddning är inte vår. */ const capKwh = React.useMemo(() => { const list = Array.isArray(settings?.batteries) ? settings.batteries : null; if (list && list.length){ const sum = list.reduce((a,b) => a + (Number(b?.capacity_kwh) || 0), 0); if (sum > 0) return sum; } // Ett-batterihushåll saknar batteries[] — kapaciteten ligger platt. const flat = Number(settings?.battery_capacity_kwh) || 0; return flat > 0 ? flat : null; }, [settings]); const kpi = React.useMemo(() => { if (!eco || !(totals.cons > 0.01)) return null; const r2 = (x) => x == null ? null : Math.round(x*100)/100; const netto = r2(eco.actual), noBat = r2(eco.noBat), nsnb = r2(eco.nsnb); return { netto, noBat, nsnb, nsnbDays: eco.nsnbDays, // Referensen är "utan sol och batteri" när dygnen bär den, annars // "utan batteri". Etiketten måste följa vilken som används. refCost: nsnb != null ? nsnb : noBat, refLabel: nsnb != null ? 'utan sol och batteri' : 'utan batteri', besparing: nsnb != null ? r2(nsnb - netto) : r2(noBat - netto), solVarde: r2(eco.solVarde), batVarde: r2(eco.batVarde), cykler: (eco.chargeKwh != null && capKwh) ? eco.chargeKwh / capKwh : null, // Härledd ur de två visade talen, inte avrundad för sig. savNoBat: (netto == null || noBat == null) ? r2(eco.savNoBat) : r2(noBat - netto), self: Math.max(0, Math.min(100, ((totals.cons - totals.imp)/totals.cons)*100)), effektivt: eco.actual/totals.cons, imp: totals.imp, exp: totals.exp, }; }, [eco, totals, capKwh]); const coverage = React.useMemo(() => { const missing = chart.rows.filter(r => r.missing).length; if (!missing) return null; const withData = n - missing; if (period === 'dag'){ return withData === 0 ? 'Inga mätvärden för dygnet ännu.' : `${withData} av ${n} timmar har mätvärden — resten av dygnet har inte inträffat.`; } const startLbl = new Date(SV_DATA_START + 'T00:00:00') .toLocaleDateString('sv-SE', { day:'numeric', month:'long' }); const unit = period === 'ar' ? (n === 1 ? 'månad' : 'månader') : 'dygn'; return withData === 0 ? `Inga dygn med data i perioden. Jämförelsen började ${startLbl} och växer ett dygn per natt.` : `${withData} av ${n} ${unit} har data. Jämförelsen började ${startLbl} och växer ett dygn per natt — grått är dygn som aldrig kommer att fyllas.`; }, [chart, n, period]); return (
{[['dag','Dag'],['vecka','Vecka'],['manad','Månad'],['ar','År']].map(([id,txt]) => ( ))}
{navLbl}
Egen el Köpt Sålt Produktion
{/* Avläsningsraden OVANFÖR grafen (André 31/8): på mobil hamnade den under fingret/nedanför synfältet när man pekade i grafen — värdet ska synas där blicken redan är, ovanför staplarna. Utan val renderas en tom rad med samma höjd så grafen inte hoppar när man pekar. */} {selRow && !selRow.missing ? (
{period === 'dag' ? `kl ${chart.labels[sel]}–${String((sel+1)%24).padStart(2,'0')}` : period === 'ar' ? SV_MONTH[sel] : (selRow.date || chart.labels[sel])} Prod {svN(selRow.prod)} Förbr {svN(selRow.cons)} Köpt {svN(selRow.imp)} Sålt {svN(selRow.exp)} kWh
) : ( )}
{chart.rows.map((r, i) => { const x = i*slot + slot*0.18, w = slot*0.64; if (r.missing) return ( ); const vs = (r.self||0)*kY, vi = (r.imp||0)*kY, ve = (r.exp||0)*kY; const on = sel === i; return ( {on && } ); })} {prodPath && } {chart.labels.map((l, i) => showTick(i) && ( {l} ))}
{[['Produktion', totals.prod, 'var(--c-charge-sol)'], ['Förbrukning', totals.cons, 'var(--ink)'], ['Egen el', totals.self, 'var(--c-cover)'], ['Köpt', totals.imp, 'var(--c-charge-net)'], ['Sålt', totals.exp, 'var(--c-sell)']].map(([l,v,c]) => (
{l}
{svN(v)}kWh
))}
{coverage && (

{coverage}

)} {win?.d30 && win.d30.total != null && (
Besparing senaste 30 dagarna {win.d30.days != null && {win.d30.days} dygn med data}
{svKr(win.d30.total)} mot att köpa all el utan sol och batteri
{[['varav batteriet', win.d30.bat], ['varav HEMS-styrningen', win.d30.hems]] .filter(([, v]) => v != null).map(([l, v]) => (
{l}{svKr(v)}
))}
{win.d7 && win.d7.total != null && (
Senaste 7 dagarna {svKr(win.d7.total)} {win.d7.hems != null && <> · styrningen {svKr(win.d7.hems)}}
)}

Raderna är inte adderbara — varje rad är en delmängd av den ovanför. Fönstret slutar igår, så dagens dygn ingår inte. {!win.d30.hemsAdj && <> Styrningens rad är ännu inte lagerjusterad.}

)} {/* Ekonomi: HEMS-styrt mot passivt batteri mot inget batteri. */}
Vad styrningen är värd {eco?.partial && Preliminär · vinsten bokförs mest på kvällen}
{!eco ? (

Inget dygn med data i perioden. Jämförelsen började{' '} {new Date(SV_DATA_START + 'T00:00:00').toLocaleDateString('sv-SE', { day:'numeric', month:'long' })} och växer ett dygn per natt — det finns ingen backfill. Dagens dygn räknas live så snart det har några kvartar.

) : ( {/* POSITIVT = styrningen kostade MINDRE än ett passivt batteri. Beloppet skrivs utan minustecken; ordet och färgen bär tecknet, precis som i staplarna. Talet är LAGERJUSTERAT när alla dygn i perioden bär fältet — den råa differensen passivt − utfall visar fel tecken ungefär var tredje dygn, eftersom energi som lagras bokförs som förlust den dagen och vinst nästa. Procenten räknas mot samma tal som visas, inte mot den råa. */} {(() => { const lead = eco.savPassiveAdj != null ? eco.savPassiveAdj : eco.savPassive; const pct = Math.abs(eco.passive) > 5 ? (lead/Math.abs(eco.passive))*100 : null; return (
{lead >= 0 ? 'Styrningen sparade' : 'Styrningen kostade'} = 0 ? 'var(--c-sell)' : 'var(--c-charge-net)' }}> {svKr(lead == null ? null : Math.abs(lead))} {lead >= 0 ? 'mot passivt batteri' : 'mer än passivt batteri'} {eco.savPassiveAdj != null && <> · lagerjusterat} {pct != null && <> · {svN(Math.abs(pct),0)} %}
); })()} {/* Vad justeringen bestod av. Utan den här raden ser ett justerat tal ut som en annan uträkning av samma sak. */} {eco.carry && eco.savPassiveAdj != null && (

Rå besparing {svKr(eco.savPassive)}, varav lagerförändring{' '} {svKr(Math.round((eco.savPassive - eco.savPassiveAdj)*100)/100)} {eco.socDeltaEnd != null && <> — batteriet slutade {svKwh(Math.abs(eco.socDeltaEnd))} kWh {eco.socDeltaEnd >= 0 ? ' fullare' : ' tommare'} än ett passivt}. {/* Backend 18/8: periodtotalen med lagerjustering innehåller värdet av SOC-gapet vid periodens SLUT minus vid starten. Ekonomiskt rätt, men ett spann som klipps vid en stor orealiserad kredit ser bättre ut än den realiserade kassan. */} {eco.socDeltaEnd == null && <> Lagervärdet vid periodens kanter räknas in.}

)} {/* Stapelns längd är beloppet, färgen är tecknet: grönt = perioden gav pengar tillbaka, blått = den kostade. Utan färgen skulle en lång stapel betyda "bäst" i ett negativt dygn och "sämst" i ett positivt. */}
{[['Utan batteri', eco.noBat], ['Passivt batteri', eco.passive], ['HEMS-styrt', eco.actual]].map(([l,v]) => { const max = Math.max(1, ...[eco.noBat, eco.passive, eco.actual].map(x => Math.abs(x||0))); const income = (v || 0) <= -0.005; return (
{l} {svKr(v)}
); })}
gav pengar tillbaka kostade

Kostnad för perioden, {eco.days} dygn med data {eco.requested > eco.days && <> av {eco.requested}}. Negativt tal är nettoinkomst. Passivt batteri laddar bara eget solöverskott och täcker underskott, med samma kapacitet och gränser som ert. {eco.hasAi && <> Det AI-styrda batteriet räknas inte in.} {' '}Jämförelsen omfattar elhandelskostnad. Effektavgift, elbilsstyrningens värde och batterislitage ingår inte.

)}
{/* Nyckeltal — hans KPI-rutnät, de fem som går på dagens kontrakt. */} {kpi && (

Elen hade kostat {svKr(kpi.refCost)} {kpi.refLabel} {' — '} {kpi.netto <= -0.005 ? <>du tjänade {svKr(Math.abs(kpi.netto))} : <>du betalade {svKr(kpi.netto)}} {kpi.besparing != null && <>, alltså = 0 ? 'pos' : 'neg'}> {svKr(Math.abs(kpi.besparing))} {kpi.besparing >= 0 ? 'billigare' : 'dyrare'}}.

{[ { l:'Total besparing', v:svKr(kpi.besparing == null ? null : Math.abs(kpi.besparing)), sub:'mot ' + kpi.refLabel, cls: kpi.besparing >= 0 ? 'pos' : 'neg', ring:true }, { l: kpi.netto <= -0.005 ? 'Din intäkt' : 'Din kostnad', v: svKr(Math.abs(kpi.netto)), sub: kpi.netto <= -0.005 ? 'netto för perioden' : 'netto för perioden', cls: kpi.netto <= -0.005 ? 'pos' : '' }, // Solcellernas och batteriets värde finns bara när dygnen bär // v2-fälten. Härledda ur samma två differenser backend anger. ...(kpi.solVarde != null ? [{ l:'Solcellerna ger', v:svKr(kpi.solVarde), sub:'mot att köpa allt', cls:'sun' }] : []), ...(kpi.batVarde != null ? [{ l:'Batteriet tillför', v:svKr(kpi.batVarde), sub:'mot sol utan batteri', cls:'pos' }] : []), { l: kpi.nsnb != null ? 'Utan sol och batteri' : 'Utan batteri', v:svKr(kpi.refCost), sub:'hade elen kostat', cls:'dim' }, { l:'Självförsörjning', v:svN(kpi.self,0)+' %', sub:'av förbrukningen utan nätköp', cls:'sun' }, { l:'Effektivt elpris', v:svN(kpi.effektivt,2)+' kr', sub:'per förbrukad kWh', cls: kpi.effektivt < 0 ? 'pos' : '' }, { l:'Köpt / sålt', v: svKwh(kpi.imp)+' / '+svKwh(kpi.exp), sub:'kWh från och till nätet', cls:'' }, ...(kpi.cykler != null ? [{ l:'Batterivarv', v:svN(kpi.cykler,1), sub:'laddad energi delat på kapacitet', cls:'' }] : []), ].map(k => (
{k.l}
{k.v}
{k.sub}
))}
{/* Tre av hans nyckeltal saknar fält. De räknas INTE fram på annat sätt — en härledd siffra som ser exakt ut är värre än ingen. */} {/* Täckning: v2-fälten skrivs från 16/8. Ett dygn utan dem räknas inte in — annars vore "inte räknat" samma sak som noll. */} {kpi.nsnb == null && (

{kpi.nsnbDays > 0 ? <>{kpi.nsnbDays} av {eco.days} dygn i perioden har jämförelsen mot "utan sol och batteri". Den kräver alla dygn för att bli ett tal — här visas i stället kostnaden utan batteri, med solen kvar. : <>Jämförelsen mot "utan sol och batteri" räknas från 16 augusti. För tidigare dygn visas i stället kostnaden utan batteri, med solen kvar.}

)}
)} {/* Sedan start — ANNAN modell än jämförelsen ovan. /api/savings/summary räknar batteriets nettoresultat per transaktion (laddkostnad mot urladdningsvärde, minus cykelkostnad), inte kostnaden mot ett passivt batteri. Den skillnaden måste stå i etiketten, annars läses de två talen som motstridiga (beslut 2026-08-16). */} {savings?.total?.net_profit_kr != null && (
Batteriets nettoresultat sedan start {savings.total.days != null && {savings.total.days} dygn}
{svKr(savings.total.net_profit_kr)} {savings.total.avg_profit_per_day_kr != null && ( {svKr(savings.total.avg_profit_per_day_kr)} per dygn )}

Annan modell än jämförelsen ovan: här räknas varje laddning mot värdet av urladdningen, minus cykelkostnad. Jämförelsen mäter i stället vad styrningen tillför mot ett passivt batteri under perioden du valt.

)}
); } Object.assign(window, { StatsV2, svHoursFromHistory, svDayRow });