/* 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']; // Gränsen bakåt är comparison.first_day (backend 20/9, alla hem båda miljöer: // 2026-07-01, Friskens 2026-08-25). Ingen hårdkodad start; innan svaret // laddat får pilen ett golv vid årets början så den inte blir oändlig. const SV_MIN_YEAR = 2026; // Dygn med färre kvartar än så i ekonomin är ofullständiga (backend 20/9): // kostnaderna bygger på del av dygnet och läses lätt som en billig dag. const SV_COVER_MIN = 90; // Dagens dygn räknas aldrig som ofullständigt — det har bara inte hänt än. const svIsPartialDay = (r) => !!r && !r.missing && r.date && r.date < svISO(new Date()) && r.coverage != null && r.coverage < SV_COVER_MIN; const svPartial = (r) => !!r && !r.missing && (r.partialDays > 0 || svIsPartialDay(r)); const svDataStart = (comparison) => (comparison && /^\d{4}-\d{2}-\d{2}$/.test(comparison.first_day || '')) ? comparison.first_day : SV_MIN_YEAR + '-01-01'; const svStartLbl = (d) => new Date(d + 'T00:00:00').toLocaleDateString('sv-SE', { day:'numeric', month:'long', year:'numeric' }); 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){ /* K3 (15/9): INGEN w-skalning. Timmens första kvart har systematiskt ~630 s (weight_sec ≈ 3 330/3 600), så w/900 drog ner varje timme 7–8 %. Timdocens home_consumption stämmer med oviktad kvartssumma. */ const f = 0.25; 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, dischargeKwh: d.actual?.discharge_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, /* v4 (20/9): adj = energy + revaluation. energy = vad energirörelsen gav, revaluation = prisändringen på ingående lager. */ savEnergy: d.savings_vs_passive_energy_kr ?? null, reval: d.revaluation_kr ?? null, carryInKwh: d.carry_in_kwh ?? null, carryOut: d.carry_out_kr ?? null, carryIn: d.carry_in_kr ?? null, carryRefPrice: d.carry_ref_price_kr ?? null, socDeltaEnd: d.soc_delta_end_kwh ?? null, socEndPct: d.actual?.soc_end_pct ?? null, savNoBat: d.savings_vs_no_battery_kr ?? null, /* Bilen som källa (backend 16/9, flagga HEMS_ECON_EV_SOURCE). Null i alla dygn räknade utan flaggan — för alltid för dygn före tändningen. */ evCharge: d.ev_charge_kwh ?? null, evDischarge: d.ev_discharge_kwh ?? null, evExport: d.ev_export_kwh ?? null, evDischargeValue: d.ev_discharge_value_kr ?? null, hasAi: d.has_ai_battery || undefined, coverage: d.coverage_quarters ?? null, }; } function StatsV2({ history, comparison, comparisonBusy, 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 dataStart = svDataStart(comparison); const maxBack = React.useMemo(() => { const min = new Date(dataStart + '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, dataStart]); /* Väntar vi på spannets ekonomi? Busy från föräldern, inget svar ännu, eller ett svar för ett annat spann än det visade. Styr snurran i graf, rutrad och ekonomi — dagsvyns graf läser history och väntar inte. */ const ecoWait = !!(comparisonBusy || (comparison == null && !!onRange) || (comparison && comparison.from && (comparison.from !== nav.from || comparison.to !== nav.to))); const chartWait = ecoWait && period !== 'dag'; /* Dag bakåt (André 20/9): history-propen är appens 7-dygnsfönster hämtat vid start — äldre dygn fanns aldrig i det och dagsvyn blev tom. Hämtar /api/history?days=N för det navigerade dygnet när propen inte täcker det. Backend klampar N till 30, så längre bak än så finns inga timmar. */ const histCovers = React.useMemo(() => (history?.consumption || []).some(r => r.date === nav.from), [history, nav.from]); const [dayHist, setDayHist] = React.useState(null); const baseISO = svISO(base); // base är ett nytt Date-objekt varje render — bind på strängen React.useEffect(() => { if (period !== 'dag' || histCovers || !window.hems?.getHistory) { return; } if (dayHist && dayHist.date === nav.from) { return; } const back = Math.round((new Date(baseISO+'T00:00:00') - new Date(nav.from+'T00:00:00'))/86400000); if (back < 0 || back > 30) { setDayHist(null); return; } let dead = false; window.hems.getHistory(back + 1).then(d => { if (!dead) setDayHist({ date: nav.from, data: d }); }).catch(() => { if (!dead) setDayHist(null); }); return () => { dead = true; }; }, [period, nav.from, histCovers, baseISO, dayHist]); const histForDay = (dayHist && dayHist.date === nav.from) ? dayHist.data : history; /* Staplarna. Dag läser timmar ur history, övriga dygn ur comparison. */ const chart = React.useMemo(() => { if (period === 'dag'){ const hrs = svHoursFromHistory(histForDay, 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, partialDays: real.filter(svIsPartialDay).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, histForDay, 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, /* v4: energi och omvärdering summeras bara när alla dygn bär dem. */ savEnergy: days.length && days.every(x => x.savEnergy != null) ? r2(days.reduce((a,x) => a + x.savEnergy, 0)) : null, reval: days.length && days.every(x => x.reval != null) ? r2(days.reduce((a,x) => a + x.reval, 0)) : null, carryInKwh: days.length === 1 ? (days[0].carryInKwh ?? null) : null, /* K1: dygnets lagerposter, bara för ett enskilt dygn. */ carryOut: days.length === 1 ? (days[0].carryOut ?? null) : null, carryIn: days.length === 1 ? (days[0].carryIn ?? null) : null, /* Periodens lager (André 16/9): carry teleskoperar, så nettot över perioden är sista dygnets carry_out minus första dygnets carry_in — värdet av energi som ligger kvar i batteriet vid periodens slut utöver passivt, minus det som redan låg där vid starten. Bara när alla dygn bär fälten. Ett dygn ger samma tal som carryOut − carryIn. */ storeNet: adj.length === days.length && adj.length && days.every(x => x.carryOut != null && x.carryIn != null) ? days[days.length-1].carryOut - days[0].carryIn : null, /* K2: ekonomins last (load_kwh) över perioden; null om något dygn saknar den. */ loadKwh: days.every(x => x.cons != null) ? sum(x => x.cons) : 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, carryRefPrice: days.length === 1 ? (days[0].carryRefPrice ?? null) : 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. */ 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]); /* BILEN SOM EGEN POST (André 16/9). Rutraden påstod en balans som inte stämmer: "Förbrukning" är HUSET (history quarters[].home) medan "Köpt" är allt som gick genom mätaren — hus, batteriladdning OCH bilen. Ett dygn med 51,6 kWh köpt mot 9,7 kWh förbrukning ser ut som ett mätfel tills bilen redovisas. Källa: GET /api/ev/history?days=N (uppmätt ur ev_actual, live 16/8) — samma endpoint som Laddhistoriken på Bil-fliken, så talen kan inte säga emot varandra. Summeras per datum inom visat spann. Svarar endpointen inte, eller finns ingen laddbox, ritas ingen ruta alls: en tom bilruta vore ett påstående om att bilen inte laddat. */ const [evHist, setEvHist] = React.useState(null); const evDays = React.useMemo(() => { const d0 = new Date(nav.from + 'T00:00:00'), b0 = new Date(base); b0.setHours(0,0,0,0); return Math.max(1, Math.min(400, Math.round((b0 - d0)/86400000) + 1)); }, [nav.from, base]); React.useEffect(() => { if (!window.hems?.getEvHistory || !window.gcFetchReady) return; let dead = false; const stop = window.gcFetchReady(() => window.hems.getEvHistory(evDays), { onOk: (d) => { if (!dead) setEvHist(d || null); }, onFail: () => { if (!dead) setEvHist(null); }, }); return () => { dead = true; if (typeof stop === 'function') stop(); }; }, [evDays]); /* Finns det något att redovisa? "Bil 0,0" hos ett hushåll utan laddbox är inte en avläsning utan en ruta om något som inte finns. FLERA BOXAR är normalfallet (André 16/9). Rutan är hushållets summa över samtliga boxar — sessionerna räknas oavsett charger_id, precis som Laddhistoriken summerar dem. Listan från evModel behåller avstängda boxar för att vara positionsbunden mot ev_power_list, så aktiva räknas för sig: räcker det med EN aktiv box för att rutan ska finnas. Uppmätt laddning väger tyngre än brytarläget: har någon box faktiskt laddat i spannet redovisas den även om samtliga boxar stängts av efteråt — energin gick genom mätaren oavsett vad brytaren står på i dag. */ const evSpan = React.useMemo(() => { const s = Array.isArray(evHist?.sessions) ? evHist.sessions : null; if (!s) return null; // endpointen svarade inte /* Kostnaden är backendens cost_kr per session (nätköpt del prissatt per kvart); solar_value_kr är soldelens värde. Summeras bara om VARJE session i spannet bär fältet — annars null, inte en delsumma. */ let tot = 0, n = 0, cost = 0, sol = 0, costOk = true; for (const r of s){ if (!r?.date || !(r.date >= nav.from && r.date <= nav.to)) continue; if (typeof r.kwh !== 'number') continue; tot += r.kwh; n++; if (typeof r.cost_kr === 'number') cost += r.cost_kr; else costOk = false; if (typeof r.solar_value_kr === 'number') sol += r.solar_value_kr; } return { kwh: tot, sessions: n, cost: n && costOk ? cost : null, solVal: n && costOk ? sol : null }; }, [evHist, nav.from, nav.to]); /* Bilens energi ur dygnsdokumentet när VARJE dygn i spannet bär fälten (backend 16/9: samma tal som sessionerna, ett anrop mindre, inget beroende av att sessionerna hittats). Annars null och sessionerna gäller. */ const evDoc = React.useMemo(() => { const days = (comparison?.days || []).filter(inRange).map(svDayRow).filter(x => !x.missing); if (!days.length || !days.every(x => x.evCharge != null)) return null; const sum = (k) => days.reduce((a,x) => a + (x[k] || 0), 0); return { charge: sum('evCharge'), discharge: sum('evDischarge'), exp: sum('evExport'), value: days.some(x => x.evDischargeValue != null) ? sum('evDischargeValue') : null }; }, [comparison, nav.from, nav.to]); const evKwh = React.useMemo(() => { if (evDoc) return evDoc.charge; if (!evSpan) return null; const list = window.evModel?.normalize ? window.evModel.normalize(settings) : []; const anyActive = list.some(c => c.active !== false); if (!anyActive && !evSpan.sessions) return null; return evSpan.kwh; }, [evDoc, evSpan, settings]); /* Urladdning ur bilen (V2H/V2G) — bara ur dygnsdokumentet, aldrig härledd. */ const evOut = evDoc && evDoc.discharge >= 0.05 ? evDoc.discharge : 0; /* Bilkostnad (André 16/9): visas när Bil-rutan visas och sessionerna bär cost_kr. Samma sessioner som rutan, så kWh och kr hör ihop. */ const evCost = (evKwh != null && evKwh >= 0.05 && evSpan?.cost != null) ? evSpan : null; /* BATTERIET SOM EGEN POST (André 16/9). Utan den går raden inte ihop: 51,6 köpt = 39,5 last + 13,9 in i batteriet − 1,4 ur − 0,4 egen sol. Netto in (charge − discharge) ur /api/economics/comparison, alltså SAMMA källa i alla fyra perioder — dagsvyns timhistorik bär ingen batterienergi, bara effekt per kvart, och två olika härledningar för samma ruta driver isär. Negativt = batteriet gav mer än det tog. */ const batNet = React.useMemo(() => { const days = (comparison?.days || []).filter(inRange).map(svDayRow) .filter(x => !x.missing && (x.chargeKwh != null || x.dischargeKwh != null)); if (!days.length) return null; return days.reduce((a,x) => a + (x.chargeKwh || 0) - (x.dischargeKwh || 0), 0); }, [comparison, inRange]); 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 = PRODUKTION − SÅLT, summerad PER RAD (rättat 16/9, två varv). Varv 1: formeln förbrukning − köpt förutsatte hela förbrukningen och klamrades till noll mot husets tal varje dygn med nattladdning. Varv 2: prod − exp på PERIODSUMMAN blev negativ en vecka där batteriet exporterat (Sålt 47,7 > Produktion 35,2) och klamrades till 0,0 fast varje soldag hade egen el. Radvis: timme i Dag, dygn i period; en batteriexportdag klamras för sig, soldagarna räknas. */ const cons = sum('cons'), imp = sum('imp'), prod = sum('prod'), exp = sum('exp'); const self = real.reduce((a,r) => a + Math.max(0, (r.prod||0) - (r.exp||0)), 0); return { prod, cons, imp, exp, self, n:real.length }; }, [chart]); /* HÅLET MELLAN HUSET OCH MÄTAREN (verifiering 16/9). Dagsvyns "Huset" är husmätningens kvartar; ekonomins load_kwh är all last bakom mätaren. För 16/9: 10,0 mot 39,5. Utan den posten går raden 29 kWh back — och en rad med Batteri och Bil i ser ut som att den SKA summera, så läsaren letar efter felet i stället för att läsa talen. "Övrigt" är därför inte en ny mätning utan differensen: last bakom mätaren som husmätaren inte ser (smartlaster, omätt förbrukning; bilen har egen ruta och dras av här). Visas bara i dagsvyn — periodvyerna räknar redan hela lasten i "Förbrukning". */ const ekoLoad = React.useMemo(() => { const days = (comparison?.days || []).filter(inRange).map(svDayRow) .filter(x => !x.missing && x.cons != null); return days.length ? days.reduce((a,x) => a + x.cons, 0) : null; }, [comparison, inRange]); /* INGEN HÄRLEDNING AV BILEN (André 16/9: "den skall inte härledas och chansas"). Ett försök att räkna fram bilen ur hålet minus uppmätta smartlaster är borta igen. Bilrutan visar UPPMÄTT laddning eller ingenting — rapporterar /api/ev/history inga sessioner för dygnet står energin kvar i Övrigt, och det är sanningen om vad vi vet. Att boxen laddar just NU utan att synas här är ett kontraktshål, inte något vi ska gissa oss runt: se BACKEND_BEHOV punkt 12. */ const split = React.useMemo(() => { const matt = evKwh != null && evKwh >= 0.05 ? evKwh : null; if (period !== 'dag' || ekoLoad == null) return { ev: matt, ovrigt: null }; const rest = ekoLoad - totals.cons - (matt || 0); return { ev: matt, ovrigt: rest >= 0.05 ? rest : null }; }, [period, ekoLoad, totals.cons, evKwh]); const evShown = split.ev || 0; const ovrigt = split.ovrigt; /* Rutraden. Ordningen är energins väg: in i huset, var den tog vägen, mot nätet. Bilen och batteriet mellan Egen el och Köpt. "Huset" i dagsvyn, "Förbrukning" i de andra: dagsvyn mäter husets kvartar (history quarters[].home) medan periodvyerna räknar ekonomins last (load_kwh), som redan bär bil och laster. Samma etikett över två baser lät 9,7 och 39,5 se ut som samma sak (André 16/9). Talet är oförändrat — bara namnet säger nu vilket det är. Bilen göms vid noll: en ruta som står på 0,0 hela vintern är inte en avläsning, den är brus (André 16/9). Batteriet visas även vid noll — det är det som får raden att gå ihop. */ const cells = React.useMemo(() => { /* BILEN UTBRUTEN ÖVERALLT (André 16/9). Periodvyernas bas är ekonomins load_kwh, som redan innehåller bilen — står Bil bredvid räknas den dubbelt av den som summerar. Därför dras uppmätt bil av från Förbrukning så raden har samma form i alla perioder: varje ruta är en addend. Dagsvyns Huset innehåller inte bilen och lämnas orörd. Saknas uppmätt bil dras inget av — då är Förbrukning hela lasten, som förut. */ const consShown = period === 'dag' ? totals.cons : Math.max(0, totals.cons - evShown); const out = [ ['Produktion', totals.prod, 'var(--c-charge-sol)'], [period === 'dag' ? 'Huset' : 'Förbrukning', consShown, 'var(--ink)'], ['Egen el', totals.self, 'var(--c-cover)'], ]; if (ovrigt != null) out.push(['Övrigt', ovrigt, 'var(--ink-2)']); /* Bil = netto in (laddat − urladdat), samma konvention som Batteri, så raden går ihop även när bilen matat huset eller nätet. Foten säger hur mycket som gick ut. */ if (evShown || evOut) out.push(['Bil', evShown - evOut, 'var(--c-ev)']); if (batNet != null) out.push(['Batteri', batNet, 'var(--c-export)']); out.push(['Köpt', totals.imp, 'var(--c-charge-net)']); out.push(['Sålt', totals.exp, 'var(--c-sell)']); return out; }, [totals, evKwh, evShown, evOut, ovrigt, batNet, period]); /* 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) return null; /* K2 (15/9, André): EN förbrukningsbas i rutnätet. Kronkorten bygger på ekonomins last (load_kwh = sol + nät − batteri, inkl. bil), så Effektivt elpris och Självförsörjning delar med samma tal. Historikens hus-kWh används bara när ekonomin saknar load_kwh. */ const inclEv = eco.loadKwh != null && eco.loadKwh > 0.01; const consBase = inclEv ? eco.loadKwh : totals.cons; if (!(consBase > 0.01)) return null; const r2 = (x) => x == null ? null : Math.round(x*100)/100; /* LAGERJUSTERAT (André 16/9): den del av utfallet som ligger kvar som energi i batteriet (carry_out, bara dagsvyn, bara när fullare än passivt) är inte förbrukad. Netto, Batteriet tillför och Total besparing räknas på förbrukat = utfall − lager, så sol + batteri = besparing på skärmen och korten säger samma som stapelraden. */ const store = (eco.carry && eco.storeNet != null && eco.storeNet > 0.005 && (eco.actual||0) > 0) ? Math.min(eco.storeNet, eco.actual) : 0; const netto = r2(eco.actual - store), noBat = r2(eco.noBat), nsnb = r2(eco.nsnb); return { netto, noBat, nsnb, store: r2(store), paid: r2(eco.actual), 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: eco.batVarde == null ? null : r2(eco.batVarde + store), 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, ((consBase - totals.imp)/consBase)*100)), effektivt: (eco.actual - store)/consBase, inclEv, imp: totals.imp, exp: totals.exp, }; }, [eco, totals, capKwh]); const partialN = React.useMemo(() => period === 'dag' ? 0 : (comparison?.days || []).filter(inRange).map(svDayRow).filter(svIsPartialDay).length, [period, comparison, inRange]); 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 hasStart = !!comparison?.first_day; const began = hasStart ? ` Jämförelsen började ${svStartLbl(dataStart)}.` : ''; const unit = period === 'ar' ? (n === 1 ? 'månad' : 'månader') : 'dygn'; return withData === 0 ? `Inga dygn med data i perioden.${began}` : `${withData} av ${n} ${unit} har data. Grått är dygn utan ekonomi.${began}`; }, [chart, n, period, dataStart, comparison?.first_day]); 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
) : ( )}
{chartWait &&
Hämtar {period === 'vecka' ? 'veckans' : period === 'manad' ? 'månadens' : 'årets'} dygn — sparade läses lokalt, bara nya hämtas.
} {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 ( {svPartial(r) && } {on && } ); })} {prodPath && } {chart.labels.map((l, i) => showTick(i) && ( {l} ))}
{cells.map(([l,v,c]) => (
{l}
{svN(v)}kWh
))}
{period !== 'dag' && evShown > 0 && (

Förbrukning är utan bilen — den står för sig. Tillsammans är de hushållets hela last.

)} {evOut > 0 && (

Bil är netto: {svKwh(evShown)} kWh laddat, {svKwh(evOut)} kWh ur bilen {evDoc.exp >= 0.05 ? <> (varav {svKwh(evDoc.exp)} kWh sålt till nätet) : null}.

)} {ovrigt != null && (

Övrigt = last bakom mätaren som husmätningen inte ser {evShown ? ', utöver bilen' : ''} — billaddning som inte rapporterats, smartlaster och omätt förbrukning. Räknat som ekonomins last ({svN(ekoLoad)} kWh) minus husets mätvärde{evShown ? ' och bilen' : ''}.

)} {coverage && (

{coverage}

)} {partialN > 0 && (

{partialN === 1 ? 'Ett dygn' : partialN + ' dygn'} i perioden {partialN === 1 ? 'är ofullständigt' : 'är ofullständiga'} (skrafferat) — mätvärden saknas för del av dygnet, så kostnad och besparing bygger på mindre än hela dygnet.

)} {/* Ekonomi: HEMS-styrt mot passivt batteri mot inget batteri. */} {/* Medan spannet hämtas (André 16/9: "verkar som systemet hängt sig") visas laddning i stället för förra spannets tal eller "Inget dygn". Svaret matchar spannet när dess from/to är det vi bad om; ett äldre svar räknas som väntande. */} {ecoWait && (
Vad anläggningen är värd
Hämtar {period === 'dag' ? 'dygnets' : 'periodens'} ekonomi — sparade dygn läses lokalt, bara nya hämtas.
)} {/* Nyckeltal — hans KPI-rutnät, de fem som går på dagens kontrakt. */} {kpi && !ecoWait && (

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.store > 0 && <> för förbrukad el (och {svKr(kpi.store)} för energi som ligger kvar i batteriet)} {kpi.besparing != null && <>, alltså = 0 ? 'pos' : 'neg'}> {svKr(Math.abs(kpi.besparing))} {kpi.besparing >= 0 ? 'billigare' : 'dyrare'}}.

{[ { l: kpi.besparing >= 0 ? 'Total besparing' : 'Total merkostnad', v:svKr(kpi.besparing == null ? null : Math.abs(kpi.besparing)), sub:'mot ' + kpi.refLabel + (kpi.store > 0 ? ' · lagerjusterat' : ''), 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.store > 0 ? '+ ' + svN(kpi.store,2) + ' kr i lager' : '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' }] : []), /* Färgen följer tecknet — ett minus i grönt lästes som "gav" (André 16/9). */ ...(kpi.batVarde != null ? [{ l: kpi.batVarde >= 0 ? 'Batteriet tillför' : 'Batteriet kostade', v:svKr(Math.abs(kpi.batVarde)), sub:'mot sol utan batteri' + (kpi.store > 0 ? ' · lagerjusterat' : ''), cls: kpi.batVarde >= 0 ? 'pos' : 'neg' }] : []), { 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: kpi.inclEv ? 'av all förbrukning inkl. bil' : 'av förbrukningen utan nätköp', cls:'sun' }, { l:'Effektivt elpris', v:svN(kpi.effektivt,2)+' kr', sub: kpi.inclEv ? 'per kWh inkl. bil' : '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:'' }, /* Bilens urladdning värderad av backend (exporterad del × säljpris, husdriven del × köppris). Ligger för sig — HEMS-besparingen bär den inte (backend 16/9). */ ...(evDoc && evDoc.value != null && evDoc.value >= 0.005 ? [{ l:'Bilen gav', v:svKr(evDoc.value), sub:'för '+svKwh(evDoc.discharge)+' kWh ur bilen', cls:'pos' }] : []), ...(evCost ? [{ l:'Bilkostnad', v:svKr(evCost.cost), sub: evCost.solVal >= 0.005 ? 'för '+svKwh(evCost.kwh)+' kWh · sol värd '+svKr(evCost.solVal) : 'för '+svKwh(evCost.kwh)+' kWh', cls:'' }] : []), ...(kpi.cykler != null ? [{ l:'Batterivarv', v:svN(kpi.cykler,1), sub:'laddad energi delat på kapacitet', cls:'' }] : []), ].map(k => (
{k.l}
{k.v}
{window.hemsVals(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" saknas för något dygn i perioden. Då 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 });