/* gc-historik.jsx — Historik i kollegans markup (André 6/10: "Vi kör på modell 13 som start"). Ersätter StatsV2 i fliken. Formen är mockupen Toppkort-mockup-v2.html, modell 3c (6/10; började som 13 + A): period och spann, ETT toppkort i två spalter (vänster kronorna med trappan, höger energin med tre staplar och självförsörjningen), grafen, Dag för dag som hopfälld tabell med sortering och CSV, och Så blev det billigare som stapel. Bottenflikarna är appens egna (gc-app.jsx). Källorna är desamma som StatsV2 (stats-v2.jsx), inget räknas fram på annat sätt: Dag /api/history consumption[].quarters[] per timme (svHoursFromHistory): sol, hus, köpt, sålt. Period /api/economics/comparison days[] (svDayRow): solar_kwh, load_kwh, actual.import/export_kwh, och kostnaderna actual (det du betalade), no_battery (sol utan batteri), passive (passivt batteri) och no_solar_no_battery (utan solceller). Saknas ett fält på något dygn i spannet visas raden inte alls; ingen delsumma (stats-v2:s regel sedan 16/8). Ekonomidygn v5 (backend 6/10): actual.grid_cost_kr/energy_cost_kr, self_sufficiency_pct/covered_kwh (solursprung i batteriet, per kvart), stored_energy_kwh/value_kr (lagrets ändring mot tomt batteri), och buy_price_kr per timrad i /api/history. Äldre dygn ger null och då gäller reservräkningen: nätkostnad ur Inställningar, självförsörjning (sol − sålt)/använt. Ekonomidygn v6, lagerboken (backend 7/10): used_energy_cost_kr (Elen du använde kostade), batt_grid_kr_start/end (köpt el i lagret, kr), batt_solar/grid_kwh_end (lagrets innehåll), battery_grid_export_* (Sålt ur batteriet), actual.energy_cost_kr. stored_energy_* används inte (B1). */ /* André 7/10: "Grafen ska ha samma färg på sol och köpt som resten av systemet." Färgerna är Översiktens (gc-oversikt.jsx: plan och Energiflöde). */ const GH_C = { sol:'#facc15', nat:'#60a5fa', bat:'#fb923c', salt:'#4ade80' }; /* Ytor (staplar, andelsstapel, trappan) i solens djupare gul #eab308, Energiflödets solring: en stor gul yta ser ljusare ut än samma gul i siffror, så ytorna får den nyans siffrorna uppfattas ha (André 7/10: "1,4-siffran måste ju ha annan färg än staplarna ... använd siffrans färg"). */ const GH_FILL = { ...GH_C, sol:'#eab308' }; const GH_WD = ['mån','tis','ons','tor','fre','lör','sön']; const GH_MON = ['jan','feb','mar','apr','maj','jun','jul','aug','sep','okt','nov','dec']; const GH_MONTH = ['januari','februari','mars','april','maj','juni','juli','augusti','september','oktober','november','december']; const GH_INFO_V5 = 'Självförsörjning är andelen av det huset använde som kom från egen sol: direkt från taket eller ur batteriet när energin där kom från solen. El som köpts in i batteriet räknas aldrig med. Räknas per kvart.'; const GH_INFO = 'Självförsörjning är andelen av det huset använde som kom från egen sol, direkt eller via batteriet: sol minus sålt, delat med använt. El som köpts in i batteriet räknas som köpt och höjer inte andelen.'; const ghN = (n, d = 1) => (n == null || !isFinite(n)) ? '—' : (Math.abs(n) < 0.5 / Math.pow(10, d) ? 0 : Number(n)).toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); /* Kronor med öre (v6, 7/10: talen ska gå att lägga bredvid fakturan). −0,00 visas som 0,00. */ const ghKr = (n, d = 2) => (n == null || !isFinite(n)) ? '—' : (Math.abs(n) < 0.5 / Math.pow(10, d) ? ghN(0, d) : (n < 0 ? '−' : '') + ghN(Math.abs(n), d)) + ' kr'; const ghISO = (dt) => dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0') + '-' + String(dt.getDate()).padStart(2, '0'); const ghDate = (iso) => new Date(iso + 'T00:00:00'); const ghMonday = (dt) => { const d = new Date(dt); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d; }; const ghIsoWeek = (dt) => { const d = new Date(Date.UTC(dt.getFullYear(), dt.getMonth(), dt.getDate())); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); return Math.ceil((((d - Date.UTC(d.getUTCFullYear(), 0, 1)) / 86400000) + 1) / 7); }; const ghDayLbl = (iso) => { const d = ghDate(iso); return `${GH_WD[(d.getDay() + 6) % 7]} ${d.getDate()} ${GH_MON[d.getMonth()]}`; }; const ghCoverMin = 90; const ghPartialDay = (r, todayISO) => !!r && !r.missing && r.date < todayISO && r.coverage != null && r.coverage < ghCoverMin; const GhIcon = ({ d, size = 16, className = '' }) => ; const GH_I = { chevL:'M15 6l-6 6 6 6', chevR:'M9 6l6 6-6 6', down:'M6 9l6 6 6-6', end:'M6 6l6 6-6 6M14 6v12' }; const GhCard = ({ children, className = '' }) =>
{children}
; const GH_H2 = 'text-[15px] font-semibold text-slate-100'; const GH_LBL = 'text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-400'; const GhBig = ({ v, u, size = 30, c = '#f8fafc' }) =>
{v}{u}
; /* Stapel med andelar. parts: [{ v, col }] */ const GhStack = ({ parts, h = 8, className = '' }) => { const tot = parts.reduce((a, p) => a + Math.max(0, p.v || 0), 0) || 1; return
{parts.filter(p => p.v > 0).map((p, i) =>
)}
; }; /* Trappan (3c): etikett | stapel från noll på gemensam skala | tal. Segment ritas i ordning. Negativa värden får ingen stapel, bara talet med minustecken. */ function GhStep({ rows, h = 14, fmt, max }){ const mx = Math.max(1e-9, max || 0, ...rows.map(r => Math.max(0, r.v || 0))); const pct = (x) => Math.max(0, Math.min(100, x / mx * 100)); /* Ett rutnät för alla rader (André 6/10: "Bars är inte syncade"): talkolumnen är gemensam, så staplarna slutar på samma linje oavsett hur brett talet är. */ return
{rows.map(r => {r.l}
{r.segs ? (() => { let x = 0; return r.segs.map((g, i) => { const w = pct(g.v), el =
; x += w; return el; }); })() : r.v > 0 &&
}
{fmt(r.v)} )}
; } const GhTile = ({ l, v, c }) =>
{l}
= 1000 ? 'text-[18px]' : 'text-[28px]'}`} style={GC_MONO}>{v == null ? '—' : ghN(v, 0).replace(/\s/g, '\u00a0')}= 1000 ? 'text-[10px] text-slate-400' : 'text-[12px] text-slate-400'}>kWh
; /* Ekonomidygn ur /api/economics/comparison. ghNum: null blir null (Number(null) är 0, så ett fält som saknas får inte bli noll). */ const ghNum = (v) => v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null); function ghDayFromApi(d){ return { ...window.svDayRow(d), gridKr: ghNum(d?.actual?.grid_cost_kr), elKrB: ghNum(d?.actual?.energy_cost_kr), noBatExp: ghNum(d?.no_battery?.export_kwh), selfPct: ghNum(d?.self_sufficiency_pct), selfKwh: ghNum(d?.self_sufficiency_covered_kwh), /* Ekonomidygn v6, lagerboken (backend 7/10, TILL_DESIGN_historik_lagerbok_v6_v2): null på dygn före v6. stored_energy_* används inte längre (B1). */ used: ghNum(d?.used_energy_cost_kr), bgChgKwh: ghNum(d?.battery_grid_charge_kwh), bgStart: ghNum(d?.batt_grid_kr_start), bgEnd: ghNum(d?.batt_grid_kr_end), solEnd: ghNum(d?.batt_solar_kwh_end), gridEnd: ghNum(d?.batt_grid_kwh_end), bexpKwh: ghNum(d?.battery_grid_export_kwh), bexpCost: ghNum(d?.battery_grid_export_cost_kr), bexpRev: ghNum(d?.battery_grid_export_revenue_kr), bexpRes: ghNum(d?.battery_grid_export_result_kr), /* AI-batteriets rad och HEMS-fälten (backend 7/10, TILL_DESIGN_samlad_ai_rad_cache_734, krav 1–2): null för hem utan AI-batteri och för dygn räknade före cf-9b79d59. */ aiVal: ghNum(d?.ai_battery_value_kr), aiChg: ghNum(d?.ai_battery_charge_kwh), aiDis: ghNum(d?.ai_battery_discharge_kwh), aiEst: d?.ai_battery_estimated === true, hemsUsed: ghNum(d?.hems_used_energy_cost_kr), hemsRes: ghNum(d?.hems_battery_grid_export_result_kr) }; } const GH_USED_INFO = 'Elen du använde kostade: det du betalade minus köpt el som laddade batteriet, plus köpt el som togs ur batteriet till priset den köptes för, och förluster i batteriet på köpt el. Sol i batteriet kostar inget. Fasta avgifter ingår inte. Räknat på kvartspris.'; /* Perioden ur comparison-dygnen. Allt-eller-inget per fält: null när något dygn saknar det. fee: { ore, vat } ur Inställningar för nätkostnaden när backendens actual.grid_cost_kr saknas på något dygn. Lagerboken (v6): Du betalade = Elen du använde kostade + ändring av köpt el i lagret (kr) − resultatet av Sålt ur batteriet. */ function ghAgg(days, fee){ if (!days.length) return null; const all = (k) => days.every(x => x[k] != null) ? days.reduce((a, x) => a + x[k], 0) : null; const some = (k) => days.reduce((a, x) => a + (x[k] || 0), 0); const r2 = (x) => x == null ? null : Math.round(x * 100) / 100; const actual = all('actual'), nsnb = r2(all('nsnb')), noBat = r2(all('noBat')), passive = r2(all('passive')); const load = all('cons'), imp = some('imp'), exp = some('exp'), prod = some('prod'); const gridBackend = all('gridKr'), elBackend = all('elKrB'); const gridKr = gridBackend != null ? r2(gridBackend) : (fee && fee.ore > 0 && actual != null && days.every(x => x.imp != null)) ? r2(imp * fee.ore / 100 * fee.vat) : null; const elKr = gridBackend != null && elBackend != null ? r2(elBackend) : gridKr != null && actual != null ? r2(actual - gridKr) : null; /* Självförsörjning: backendens covered_kwh summerat och delat med använt (v5); annars (sol − sålt)/använt. */ const selfBackend = days.every(x => x.selfKwh != null && x.cons != null) && load > 0.01 ? days.reduce((s, x) => s + x.selfKwh, 0) / load * 100 : days.every(x => x.selfPct != null && x.cons != null) && load > 0.01 ? days.reduce((s, x) => s + x.selfPct * x.cons, 0) / load : null; const sorted = days.slice().sort((x, y) => x.date < y.date ? -1 : 1), first = sorted[0], last = sorted[sorted.length - 1]; /* carry mot passivt: bara reserv för Styrningen i Månad/År när lagerboken saknas. */ const storeNet = days.every(x => x.carryOut != null && x.carryIn != null) ? r2(last.carryOut - first.carryIn) : null; const used = r2(all('used')); /* Ändring av köpt el i lagret: kostnadsbasen är kedjad, så perioden är sista dygnets slut minus första dygnets start. */ const dLag = days.every(x => x.bgStart != null && x.bgEnd != null) ? r2(last.bgEnd - first.bgStart) : null; /* Sålt ur batteriet (H2): summan av alla dygn, även de under tröskeln. */ const bx = days.every(x => x.bexpKwh != null && x.bexpRes != null) ? { kwh: r2(all('bexpKwh')), cost: r2(all('bexpCost')), rev: r2(all('bexpRev')), res: r2(all('bexpRes')) } : null; /* I batteriet vid periodens slut = sista dygnets lager (samma som totals.inventory_end). */ const inv = last.solEnd != null && last.gridEnd != null ? { soc: last.socEndPct ?? null, solar: last.solEnd, grid: last.gridEnd, kwh: r2(last.solEnd + last.gridEnd), gridKr: last.bgEnd } : null; const base = used != null ? used : actual; /* Ändring A med HEMS-fälten (krav 2): per dygn hems_* när värdet finns, annars hela husets. Utan AI-batteri blir det exakt A. */ const hU = (x) => x.hemsUsed != null ? x.hemsUsed : x.used, hR = (x) => x.hemsRes != null ? x.hemsRes : (x.bexpRes || 0); const hemsUsed = days.every(x => hU(x) != null) ? r2(days.reduce((s, x) => s + hU(x), 0)) : null, hemsRes = r2(days.reduce((s, x) => s + hR(x), 0)); /* AI-batteriets rad (krav 1): bara när alla dygn har värdet; ungefärligt när något dygn räknats på timmedel. */ const ai = days.every(x => x.aiVal != null) ? { v: r2(all('aiVal')), chg: some('aiChg'), dis: some('aiDis'), est: days.some(x => x.aiEst) } : null; return { n: days.length, actual: r2(actual), nsnb, noBat, passive, load, imp, exp, prod, storeNet, socEnd: last.socEndPct ?? null, used, usedPartial: used == null && days.some(x => x.used != null), dLag, bx, inv, hemsUsed, hemsRes, ai, gridKr, elKr, gridSrc: gridBackend != null ? 'backend' : 'settings', selfSrc: selfBackend != null ? 'backend' : 'flow', own: load != null ? Math.max(0, Math.min(load, prod - exp)) : null, self: selfBackend != null ? Math.round(Math.max(0, Math.min(100, selfBackend))) : load != null && load > 0.01 ? Math.round(Math.max(0, Math.min(1, (prod - exp) / load)) * 100) : null, solVal: nsnb != null && noBat != null ? r2(nsnb - noBat) : null, batVal: noBat != null && passive != null ? r2(noBat - passive) : null, /* Batteriet tillförde (D1, ändring A) = sol utan batteri − elen du använde kostade + resultatet av Sålt ur batteriet, båda utan AI-batteriet (HEMS-fälten). */ batD1: noBat != null && hemsUsed != null ? r2(noBat - hemsUsed + hemsRes) : null, /* Sparat (backend 8/10, beslut André 7/10 "med resultatet"): hela hemmet, AI-batteriet också. Med lagerboken utan solceller − kostade + Σ resultatet av Sålt ur batteriet (hela hemmets fält), så Sparat = summan av raderna i Så blev det billigare. Utan lagerboken som förut: utan solceller − betalt. */ saved: nsnb != null && base != null ? r2(used != null ? nsnb - used + days.reduce((s, x) => s + (x.bexpRes ?? 0), 0) : nsnb - base) : null, partial: days.some(x => x.partial), hasAi: days.some(x => x.hasAi) }; } /* Toppkortet, modell 3c/3d (André 6/10) med lagerboken (v6, 7/10): tre fält med sol, köpt, sålt; I batteriet som energi (inte kronor); Elen du använde kostade och Självförsörjning; trappan med Du betalade delad i nätkostnad och elpris; raden som stämmer av betalt mot kostade; Sålt ur batteriet; Så blev det billigare. Förklaringarna bakom (i). */ function GhTop({ a, wait, firstDay, period, days, complete, onJump }){ const [info, setInfo] = React.useState(false); if (wait) return
Hämtar periodens dygn…
; if (!a) return

Inget dygn med data i perioden.{firstDay && <> Historiken börjar {ghDayLbl(firstDay)} {ghDate(firstDay).getFullYear()}.} Dagens dygn räknas så snart det har några kvartar.

; const hasUsed = a.used != null; const head = hasUsed ? { l:'Elen du använde kostade', v:a.used } : { l:'Du betalade', v:a.actual }; /* Delningen ritas bara när båda delarna är positiva; nätnyttan kan göra nätkostnaden negativ och elpriset kan bli negativt. Talen står alltid i raden under. */ const split = a.gridKr != null && a.elKr != null && a.gridKr >= 0 && a.elKr >= 0 && a.actual > 0; const ladder = [a.nsnb != null && { l:'Utan solceller', v:a.nsnb, c:'#475569', dim:true }, a.noBat != null && { l:'Sol utan batteri', v:a.noBat, c:GH_FILL.sol }, hasUsed && { l:'Elen du använde', v:a.used, c:'#cbd5e1', strong:true }, a.actual != null && !hasUsed && { l:'Du betalade', v:a.actual, strong:!hasUsed, segs: split ? [{ v: a.gridKr, c:'#2f5fb8' }, { v: a.elKr, c:GH_C.nat }] : null, c:GH_C.nat }].filter(Boolean); /* Skalan räknas på alla rader, även Du betalade när den är dold, så staplarna inte hoppar när (i) öppnas. */ const ladderMax = Math.max(0, a.nsnb || 0, a.noBat || 0, a.used || 0, a.actual || 0); const Leg = ({ c, children }) => {children}; const B = ({ children }) => {children}; const signKr = (v) => (v >= 0.005 ? '+' : '') + ghKr(v); const invWhen = complete ? (period === 'dag' ? 'vid dygnets slut' : 'vid periodens slut') : 'senast'; const lvl = a.inv && a.inv.soc != null ? a.inv.soc : a.socEnd; const showBx = a.bx && a.bx.kwh >= 0.05; return
{/* I batteriet (krav 3, B1): nivå och energi vid slutet, ingen kronvärdering. Köpt-delen bara när den har kostat minst 1 öre. */} {(lvl != null || a.inv) &&
I batteriet
{lvl == null ? '—' : ghN(lvl, 0)}%
{a.inv &&
{invWhen}
}
{/* Innehållet som tabell: Sol, Köpt (med vad den kostade) och Totalt. Sol och Köpt bara när köpt-delen kostat minst 1 öre (krav 3). */} {a.inv &&
{a.inv.gridKr != null && a.inv.gridKr >= 0.01 && Sol{ghN(a.inv.solar, 1)} kWh Köpt{ghN(a.inv.grid, 1)} kWh{ghKr(a.inv.gridKr)} } Totalt{ghN(a.inv.kwh, 1)} kWh
}
}
{head.l}
= 1000 ? 24 : 30}/>
Självförsörjning
{ladder.length > 0 &&
ghKr(v)}/>
} {a.gridKr != null && !hasUsed &&
nätkostnad {ghKr(a.gridKr)}{a.elKr != null && elpris {ghKr(a.elKr)}}
} {/* Avstämningen (målet i v6): betalt = kostade + ändring av köpt el i lagret − resultatet av sålt ur batteriet. Synlig, inte bakom (i). */} {/* Från använt till betalt (André 7/10: "Med information utfälld blir det rörigt"): avstämningen som uppställning bakom (i), ett tal per rad. Elen du använde ± köpt el i lagret ∓ resultatet av sålt ur batteriet = Du betalade, och Du betalade delad i nätkostnad och elpris. */} {hasUsed && info && a.actual != null && (() => { const R = ({ sg, l, v, strong, sub, dot }) => {sg} {dot && }{l} {ghKr(v)}; const lag = a.dLag != null && Math.abs(a.dLag) >= 0.005, res = a.bx && Math.abs(a.bx.res) >= 0.005; return
Från använt till betalt
{a.dLag != null && } {lag && (a.dLag > 0 ? : )} {res && (a.bx.res > 0 ? : )} {a.dLag != null && } {a.gridKr != null && } {a.gridKr != null && a.elKr != null && }
; })()} {/* Vad batteriet gjorde (3d): ett dygn per rad i Dag och Vecka, bara uppmätta tal ur comparison: in, ut, sol, köpt. */} {/* Energi per dygn (André 7/10: döpt om från Vad batteriet gjorde, Sol och Köpt tillbaka): två grupper. Batteriet: in och ur (actual.charge_kwh/discharge_kwh) och varav köpt (battery_grid_charge_kwh, v6). Hemmet: hela dygnets sol och köpt (solar_kwh, actual.import_kwh). Pågående dygn märks med *. */} {/* André 7/10: "i månadsvy skall detta vara vecko sammanställning". Dag och Vecka en rad per dygn, Månad en rad per vecka, År en rad per månad. Summor ur dygnen; varav köpt bara när alla dygn i raden bär fältet. */} {days && days.length > 0 && (() => { const unit = period === 'manad' ? 'vecka' : period === 'ar' ? 'manad' : 'dygn'; const gkey = (x) => unit === 'vecka' ? 'v' + String(ghIsoWeek(ghDate(x.date))).padStart(2, '0') : unit === 'manad' ? x.date.slice(0, 7) : x.date; const glbl = (x) => unit === 'vecka' ? 'v. ' + ghIsoWeek(ghDate(x.date)) : unit === 'manad' ? GH_MON[ghDate(x.date).getMonth()] : GH_WD[(ghDate(x.date).getDay() + 6) % 7]; const map = new Map(); for (const x of days){ const k = gkey(x); if (!map.has(k)) map.set(k, { date: k, lbl: glbl(x), first: x.date, chargeKwh: 0, dischargeKwh: 0, prod: 0, imp: 0, bgChgKwh: 0, bgAll: true, partial: false, n: 0 }); const g = map.get(k); g.n++; g.chargeKwh += x.chargeKwh || 0; g.dischargeKwh += x.dischargeKwh || 0; g.prod += x.prod || 0; g.imp += x.imp || 0; if (x.bgChgKwh == null) g.bgAll = false; else g.bgChgKwh += x.bgChgKwh; if (x.partial) g.partial = true; if (x.date < g.first) g.first = x.date; } /* Hela perioden för raden: 7 dygn i en vecka, månadens dygn i en månad. Färre dygn (veckan delas av månaden, eller dygn saknas) står under etiketten (André 7/10). */ const ds = [...map.values()].map(g => { const f = ghDate(g.first), full = unit === 'vecka' ? 7 : unit === 'manad' ? new Date(f.getFullYear(), f.getMonth() + 1, 0).getDate() : 1; return { ...g, full, odd: g.partial || (unit !== 'dygn' && g.n < full), bgChgKwh: g.bgAll ? g.bgChgKwh : null }; }).sort((x, y) => x.first < y.first ? -1 : 1).filter(x => x.chargeKwh + x.dischargeKwh >= 1), part = ds.some(x => x.odd); if (!ds.length) return null; const big = ds.some(x => Math.max(x.chargeKwh, x.dischargeKwh, x.prod, x.imp, x.bgChgKwh || 0) >= 999.95), nd = big ? 0 : 1; const gk = ds.some(x => x.bgChgKwh != null), nb = gk ? 3 : 2; const C = (gk ? 'grid grid-cols-[42px_repeat(5,minmax(0,1fr))]' : 'grid grid-cols-[42px_repeat(4,minmax(0,1fr))]') + ' gap-x-2'; return
{unit === 'vecka' ? 'Energi per vecka' : unit === 'manad' ? 'Energi per månad' : 'Energi per dygn'}kWh
BatterietHemmet
In{gk && varav köpt}UrSolKöpt
{ds.map(x => { const to = unit === 'dygn' ? 'dag' : unit === 'vecka' ? 'vecka' : 'manad', can = !!onJump && !(unit === 'dygn' && period === 'dag'); const Row = can ? 'button' : 'div'; return onJump(to, x.first || x.date), 'aria-label': 'Visa ' + (unit === 'dygn' ? ghDayLbl(x.first || x.date) : unit === 'vecka' ? 'vecka ' + x.lbl.replace('v. ', '') : GH_MONTH[ghDate(x.first).getMonth()]) } : {})} className={C + ' w-full items-baseline border-b border-slate-800/60 py-2 text-left text-[13px]' + (can ? ' active:bg-slate-800/50' : '')} style={GC_MONO}> {x.lbl}{x.partial || (unit !== 'dygn' && x.n < x.full) ? '*' : ''} {ghN(x.chargeKwh || 0, nd)} {gk && {x.bgChgKwh == null ? '–' : ghN(x.bgChgKwh, nd)}} {ghN(x.dischargeKwh || 0, nd)} {ghN(x.prod || 0, nd)} {ghN(x.imp || 0, nd)}; })}

{part && (unit === 'dygn' ? '* hittills i dag. ' : unit === 'vecka' ? '* inte hel vecka. ' : '* inte hel månad. ')}{onJump && !(unit === 'dygn' && period === 'dag') && (unit === 'dygn' ? 'Tryck på en dag för att visa den.' : unit === 'vecka' ? 'Tryck på en vecka för att visa den.' : 'Tryck på en månad för att visa den.')}

; })()} {/* Sålt ur batteriet (krav 5, H2): köpt el som gick från batteriet till nätet, när mängden avrundas till minst 0,1 kWh. */} {showBx &&
Sålt ur batteriet{ghN(a.bx.kwh, 1)} kWh köpt el {signKr(a.bx.res)} sålt för {ghKr(a.bx.rev)}, köpt för {ghKr(a.bx.cost)}
} {info &&

{a.selfSrc === 'backend' ? GH_INFO_V5 : GH_INFO}{hasUsed && ' ' + GH_USED_INFO}{a.gridKr != null && <> Elpriset är spotpris och elhandlarmarginal med moms, efter såld el. Nätkostnaden är nätavgift och energiskatt med moms{a.gridSrc === 'settings' ? ' på köpt el, räknad på värdena i Inställningar i dag' : ' på köpt el minus nätnyttan på såld el, med den taxa som gällde när dygnet räknades'}.}

} {a.usedPartial &&

Elen du använde saknas för något dygn i perioden och visas därför inte.

} {a.nsnb == null && a.actual != null &&

Jämförelsen mot utan solceller saknas för något dygn i perioden och visas därför inte.

} {a.actual != null && }
; } /* Så blev det billigare: Solcellerna gav (utan solceller − sol utan batteri) och Batteriet tillförde (sol utan batteri − elen du använde kostade + resultatet av sålt ur batteriet). Hel månad och helt år: batteriet delas i passivt batteri (sol utan batteri − passivt) och Styrningen sparade (passivt − elen du använde kostade + resultatet av sålt ur batteriet). Kostade och resultatet är HEMS-fälten, utan AI-batteriet (ändring A, backend 7/10). AI-batteriet (styrs inte av HEMS) står som egen rad när alla dygn har värdet, så raderna summerar till utan solceller − kostade + resultatet. Utan lagerboken räknas mot det du betalade som förut. Negativa delar står med minustecken. */ function GhWhyBody({ a, period, info, complete }){ const longP = (period === 'manad' || period === 'ar') && complete; const hasUsed = a.used != null, r2 = (x) => Math.round(x * 100) / 100; const bxr = hasUsed && a.hemsRes != null && Math.abs(a.hemsRes) >= 0.005 ? a.hemsRes : 0; const ctrl = a.passive == null ? null : hasUsed ? (a.hemsUsed != null ? r2(a.passive - a.hemsUsed + bxr) : null) : a.actual != null ? r2(a.passive - a.actual + (a.storeNet || 0)) : null; const bxSub = hasUsed && bxr ? `varav sålt ur batteriet ${bxr > 0 ? '+' : ''}${ghKr(bxr)}` : null; const batShort = hasUsed ? a.batD1 : (a.noBat != null && a.actual != null ? r2(a.noBat - a.actual) : null); const parts = [a.solVal != null && { l:'Solcellerna gav', v:a.solVal, col:GH_FILL.sol, s:'mot att köpa all el' }, longP && a.batVal != null && { l:'Batteriet tillförde', v:a.batVal, col:GH_C.bat, s:'mot sol utan batteri' }, longP && ctrl != null && { l:'Styrningen sparade', v:ctrl, col:GH_C.salt, s:'mot batteri utan styrning', sub: bxSub }, !longP && batShort != null && { l:'Batteriet tillförde', v:batShort, col:GH_C.bat, s:'mot sol utan batteri', sub: bxSub }, a.ai && { l:'AI-batteriet', v:a.ai.v, col:'#94a3b8', s:'(styrs inte av HEMS)', sub: `laddade ${ghN(a.ai.chg, 1)} kWh, laddade ur ${ghN(a.ai.dis, 1)} kWh${a.ai.est ? ', ungefärligt' : ''}` }].filter(Boolean); if (!parts.length) return null; const sum = r2(parts.reduce((s, p) => s + p.v, 0)), full = parts.length === (longP ? 3 : 2) + (a.ai ? 1 : 0), many = ['', 'Den', 'De två', 'De tre', 'De fyra'][parts.length]; const neg = parts.some(p => p.v <= -0.005); return
{full && sum < 0 ? 'Så blev det' : 'Så blev det billigare'}
({ v: Math.max(0, p.v), col: p.col }))}/>
    {parts.map(p =>
  • {p.l}{p.s}{ghKr(p.v)}{p.sub && {p.sub}}
  • )}
{info &&

{full ? (sum < 0 ? `${many} summerar till ${ghKr(-sum)} mer än utan solceller.` : `${many} summerar till ${ghKr(sum)} mot utan solceller.`) : 'Delar som saknar jämförelse för något dygn i perioden visas inte.'}{hasUsed && ' Batteriet räknas mot elen du använde kostade: köpt el som ligger kvar i batteriet räknas inte som kostnad, och köpt el från tidigare räknas till priset den köptes för. Vinsten när köpt el säljs ur batteriet räknas till ' + (longP ? 'styrningen.' : 'batteriet.')}{longP && ' Säljer HEMS lagrad sol vid högt pris räknas skillnaden mot vad huset sedan fick köpa tillbaka, med nätavgift, skatt och moms.'}{!longP && a.solVal != null && ' Solcellernas värde är räknat som om batteriet inte fanns: sol som laddar batteriet får då säljprisets värde den dagen, och resten syns på batteriraden.'}{!longP && ' Styrningens egen del, mot ett batteri utan styrning som bara laddar solöverskott, visas för hela månader och år.'}{longP && ' Batteri utan styrning laddar bara solöverskott och täcker huset när solen inte räcker.'}{neg && ' En gul del kostade mer än sin jämförelse; stapeln visar bara det som sparade.'} Effektavgift och batterislitage ingår inte.

}
; } /* Grafen (André 7/10, Historik-graf-mockup-v2.html: "snyggt, den kör vi på"). Två lägen i samma kort, valda med två ikonknappar i datumraden och sparade i webbläsaren: Samlat = egen el och köpt staplade över linjen, sålt under (A); Var för sig = tre rader med samma skala (E). Staplarna glider mellan lägena. Fritt läge: alla staplar i full färg och rutorna visar periodens summor. Håll på grafen (pek) eller för musen över den: de andra tonas ner och rutorna visar den stapeln; släpp eller lämna grafen för fritt läge. Bara stapelytan är träffyta. Förklaringen bakom (i). */ const GH_CHART_KEY = 'hems_hist_chart_mode'; function GhChart({ period, bars, ptitle, pcount, partialN }){ const [mode, setMode] = React.useState(() => { try { return localStorage.getItem(GH_CHART_KEY) === 'split' ? 'split' : 'tot'; } catch (_) { return 'tot'; } }); const [act, setAct] = React.useState(false), [sel, setSel] = React.useState(0), [info, setInfo] = React.useState(false), [stag, setStag] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { setAct(false); }, [period, bars.length]); const setM = (m) => { if (m === mode) return; setStag(true); setMode(m); try { localStorage.setItem(GH_CHART_KEY, m); } catch (_) {} setTimeout(() => setStag(false), 900); }; const n = bars.length, H = 236, ML = 30, TOP = 14, BOT = 18, PH = H - TOP - BOT; const COL = [GH_FILL.sol, GH_C.nat, GH_C.salt], NAMES = ['Egen el', 'Köpt', 'Sålt']; const v = (b, k) => !b || b.missing ? 0 : Math.max(0, (k === 0 ? b.own : k === 1 ? b.imp : b.exp) || 0); const nice = (x) => { if (!(x > 0)) return 1; const p = Math.pow(10, Math.floor(Math.log10(x))), f = x / p; return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * p; }; const maxT = nice(Math.max(0, ...bars.map(b => v(b, 0) + v(b, 1)), ...bars.map(b => v(b, 2)))); const maxS = nice(Math.max(0, ...bars.map(b => Math.max(v(b, 0), v(b, 1), v(b, 2))))); const Y0 = TOP + PH * 0.66, kU = (Y0 - TOP) / maxT, kD = (TOP + PH - Y0) / maxT; const BT = (k) => 4 + k * 74, BB = (k) => BT(k) + 64, kB = 50 / maxS; const geo = (k, b) => { if (mode === 'tot'){ const o = v(b, 0) * kU, m = v(b, 1) * kU; if (k === 0) return { t: Y0 - 1 - o, h: o }; if (k === 1) return { t: Y0 - 1 - o - m, h: m }; return { t: Y0 + 2, h: v(b, 2) * kD }; } const h = v(b, k) * kB; return { t: BB(k) - h, h }; }; const fmtN = (x) => ghN(x, 1); const bg = (k) => `linear-gradient(180deg, ${COL[k]} 0%, ${COL[k]} 45%, ${COL[k]}D9 100%)`; const X = (i) => `calc(${ML}px + (100% - ${ML}px) * ${i / Math.max(1, n)})`, SW = `(100% - ${ML}px) / ${Math.max(1, n)}`; const pick = (e, start) => { const el = ref.current; if (!el || !n) return; const r = el.getBoundingClientRect(), fx = e.clientX - r.left - ML, w = r.width - ML, inside = fx >= 0 && fx < w; if (!inside && (e.pointerType === 'mouse' || start)){ setAct(false); return; } setSel(Math.max(0, Math.min(n - 1, Math.floor(fx / w * n)))); setAct(true); }; const b = act ? bars[Math.min(sel, n - 1)] : null; const sums = [0, 1, 2].map(k => bars.reduce((s, x) => s + v(x, k), 0)); const tile = (k) => { const val = act ? (b && !b.missing ? v(b, k) : null) : sums[k], big = val != null && val >= 999.95; return
{NAMES[k]}
{val == null ? '—' : fmtN(val).replace(/\s/g, '\u00a0')}kWh
; }; /* Priskurva i Dag och Samlat (köppris per timme ur /api/history, 809). */ const prices = bars.map(x => x.price).filter(p => p != null); const pMax = prices.length ? Math.max(0.5, ...prices) : 0, pMin = prices.length ? Math.min(0, ...prices) : 0; const pY = (p) => TOP + 4 + (1 - (p - pMin) / Math.max(1e-9, pMax - pMin)) * (Y0 - TOP - 14); const pPts = prices.length ? bars.map((x, i) => x.price == null ? null : `${((i + 0.5) / n * 1000).toFixed(1)},${pY(x.price).toFixed(1)}`) : []; const pPath = pPts.reduce((acc, pt, i) => pt == null ? acc : acc + (i > 0 && pPts[i - 1] != null ? ' L' : ' M') + pt, '').trim(); const ticks = bars.map((x, i) => { if (period === 'dag') return i % 6 === 0 ? String(i).padStart(2, '0') : null; if (period === 'ar') return x.lbl ? x.lbl.slice(0, 1).toUpperCase() : null; const d = ghDate(x.date); if (period === 'vecka') return GH_WD[(d.getDay() + 6) % 7]; return d.getDay() === 1 ? String(d.getDate()) : null; }); const withData = bars.filter(x => !x.missing).length; const GL = ({ y, c = '#1e293b', dash }) =>
; const YL = ({ y, t, style }) =>
{t}
; const kG = (x) => ghN(x, x < 10 && x % 1 ? 1 : 0); return
{act && b ? <>{b.lbl}{b.price != null && {ghN(b.price, 2)} kr/kWh} : <>{ptitle}{pcount && {pcount}}}
{[['tot', 'Samlat', ], ['split', 'Var för sig', ]].map(([m, l, svg]) => )}
{[0, 1, 2].map(tile)}
{ if (e.pointerType !== 'mouse') { try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} } pick(e, true); }} onPointerMove={(e) => { if (e.pointerType === 'mouse' || act) pick(e, false); }} onPointerUp={(e) => { if (e.pointerType !== 'mouse') setAct(false); }} onPointerCancel={() => setAct(false)} onPointerLeave={(e) => { if (e.pointerType === 'mouse') setAct(false); }}>
kWh
{[0, 1, 2].map(k =>
{NAMES[k]}
)}
{bars.map((x, i) => [0, 1, 2].map(k => { const g = x.missing ? (k === 0 ? { t: (mode === 'tot' ? Y0 : BB(0)) - 1, h: 2 } : { t: mode === 'tot' ? Y0 : BB(k), h: 0 }) : geo(k, x); return
; }))} {pPath && }
{ticks.map((t, i) => t == null ? null : {t})}
{info &&

{mode === 'tot' ? 'Över linjen det huset använde: egen el (sol direkt och ur batteriet) närmast linjen, köpt överst. Under linjen det som såldes.' : 'Egen el, köpt och sålt i var sin rad med samma skala. Varje stapel läses från noll.'}{mode === 'tot' && prices.length > 0 && ' Den vita linjen är köppriset per timme med avgifter och moms.'} Håll på grafen, eller för musen över den, för att se en stapel; rutorna överst visar annars hela perioden. {period === 'dag' ? (withData < n ? ` ${withData} av ${n} timmar har mätvärden.` : '') : ` ${withData} av ${n} ${period === 'ar' ? 'månader' : 'dygn'} har data.`} {partialN > 0 && ` ${partialN === 1 ? 'Ett dygn är ofullständigt' : partialN + ' dygn är ofullständiga'} (svagare stapel): mätvärden saknas för del av dygnet.`}

} ; } /* Dag för dag (Timme för timme i Dag, månad för månad i År): hopfälld tabell, sortering på rubrikerna, CSV. */ /* Kolumnfärger efter appens regler (André 7/10: "Färga efter våra regler"): sol gul, köpt blå, sålt grön, batteriets innehåll orange; resultat och sparat grönt vid plus och gult vid minus. Kronor i övrigt vita. */ const GH_TCOL = { prod: GH_C.sol, own: GH_C.sol, imp: GH_C.nat, exp: GH_C.salt, inv: GH_C.bat, price: '#e2e8f0' }; function GhTable({ period, rows, cols, title, onJump }){ const [open, setOpen] = React.useState(false); const [sort, setSort] = React.useState({ k:'d', dir:1 }); const sorted = rows.slice().sort((x, y) => { const k = sort.k; const vx = k === 'd' ? x.ord : (x[k] ?? -Infinity), vy = k === 'd' ? y.ord : (y[k] ?? -Infinity); return (vx > vy ? 1 : vx < vy ? -1 : 0) * sort.dir; }); /* Summa per kolumn; I batt. är ett lager, så summaraden visar sista radens värde. */ const tot = {}; cols.forEach((c) => { const k = c[0]; if (k === 'd') tot.d = 'Summa'; else if (c[4] === 'last'){ const r = rows.slice().sort((x, y) => x.ord > y.ord ? 1 : -1).pop(); tot[k] = r ? (r[k] ?? null) : null; } else tot[k] = rows.every(r => r[k] == null) ? null : rows.reduce((a, r) => a + (r[k] || 0), 0); }); const fmt = (k, v) => k === 'd' ? v : v == null ? '–' : ghN(v, cols.find(c => c[0] === k)[2]); const click = (k) => setSort(s => s.k === k ? { k, dir: -s.dir } : { k, dir: k === 'd' ? 1 : -1 }); const csv = () => { const esc = (s) => '"' + String(s).replace(/"/g, '""') + '"'; const lines = [cols.map(c => esc(c[1] + (c[0] === 'd' ? '' : c[3] ? ' (' + c[3] + ')' : ''))).join(';'), ...sorted.map(r => cols.map(([k]) => k === 'd' ? esc(r.d) : (r[k] == null ? '' : ghN(r[k], 2))).join(';')), cols.map(([k]) => k === 'd' ? esc('Summa') : (tot[k] == null ? '' : ghN(tot[k], 2))).join(';')]; const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type:'text/csv;charset=utf-8' }), url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = `hems-historik-${title.replace(/[^a-zåäö0-9]+/gi, '-').toLowerCase()}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; return {open &&
{cols.map(([k, l]) => )}{sorted.map(r => {cols.map(([k]) => { const sg = (k === 'saved' || k === 'bexp') && r[k] != null && Math.abs(r[k]) >= 0.005 ? (r[k] > 0 ? '#6ee7b7' : '#fcd34d') : null; /* André 7/10: "trycker man på dagen ska man komma till dagen i historiken". Dag → Dag, månad (År) → Månad. Timmar i Dag är ingen länk. */ const jump = k === 'd' && onJump && period !== 'dag' ? () => onJump(period === 'ar' ? 'manad' : 'dag', period === 'ar' ? r.key + '-01' : r.key) : null; return ; })})}{cols.map(([k]) => )}
{jump ? : fmt(k, r[k])}
{fmt(k, tot[k])}

{period === 'dag' ? 'Energi i kWh.' : cols.some(c => c[0] === 'used') ? 'Kr och kWh. Betalt = Kostade + Δ lager − Sålt ur batt. Δ lager är ändringen av köpt el i batteriet i kronor, I batt. innehållet vid dygnets slut. Sparat är mot utan solceller.' : 'Kr och kWh. Sparat är mot utan solceller.'} Tryck på en rubrik för att sortera{period !== 'dag' && onJump ? (period === 'ar' ? ', på en månad för att visa den' : ', på en dag för att visa den') : ''}.

}
; } function GcHistorik({ history, comparison, comparisonBusy, today, onRange, settings }){ const [period, setPeriod] = React.useState('vecka'); const [offset, setOffset] = React.useState(0); const [sel, setSel] = React.useState(0); /* Nätkostnadens taxa ur Inställningar (öre/kWh utan moms) och momsen. 0 öre = inte ifyllt ⇒ ingen delning. */ const fee = React.useMemo(() => { const ore = (Number(settings?.grid_fee_kwh ?? settings?.grid_fee_ore) || 0) + (Number(settings?.energy_tax_kwh ?? settings?.energy_tax_ore) || 0); const vp = settings?.vat_percent ?? settings?.vat_pct; return { ore, vat: 1 + (Number.isFinite(Number(vp)) ? Number(vp) : 25) / 100 }; }, [settings]); const jumpOff = React.useRef(null); React.useEffect(() => { if (jumpOff.current != null){ setOffset(jumpOff.current); jumpOff.current = null; } else setOffset(0); }, [period]); const rootRef = React.useRef(null); const base = today || (window.hemsDate ? window.hemsDate() : new Date()); const todayISO = ghISO(base); const nav = React.useMemo(() => { if (period === 'dag'){ const d = new Date(base); d.setDate(d.getDate() + offset); return { from: ghISO(d), to: ghISO(d), date: d }; } if (period === 'vecka'){ const m = ghMonday(base); m.setDate(m.getDate() + offset * 7); const e = new Date(m); e.setDate(e.getDate() + 6); return { from: ghISO(m), to: ghISO(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: ghISO(s), to: ghISO(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, todayISO]); const needsToday = nav.to >= todayISO; React.useEffect(() => { if (onRange) onRange(nav.from, nav.to, needsToday); }, [nav.from, nav.to, needsToday, onRange]); const firstDay = comparison && /^\d{4}-\d{2}-\d{2}$/.test(comparison.first_day || '') ? comparison.first_day : null; const maxBack = React.useMemo(() => { const min = ghDate(firstDay || (base.getFullYear() + '-01-01')), 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((ghMonday(b0) - ghMonday(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, todayISO, firstDay]); /* Väntar vi på spannets ekonomi? Som stats-v2: busy, inget svar, eller ett svar för ett annat spann. */ const wait = !!(comparisonBusy || (comparison == null && !!onRange) || (comparison && comparison.from && (comparison.from !== nav.from || comparison.to !== nav.to))); const inRange = (d) => !!d?.date && d.date >= nav.from && d.date <= nav.to; const days = React.useMemo(() => wait ? [] : (comparison?.days || []).filter(inRange).map(ghDayFromApi).filter(x => !x.missing).map(x => ({ ...x, partial: x.partial || ghPartialDay(x, todayISO) })), [comparison, nav.from, nav.to, wait, todayISO]); const a = React.useMemo(() => ghAgg(days, fee), [days, fee]); /* Dag bakåt: history-propen är appens 7-dygnsfönster; äldre dygn hämtas för sig (backend klampar till 30 dygn). */ const histCovers = React.useMemo(() => (history?.consumption || []).some(r => r.date === nav.from), [history, nav.from]); const [dayHist, setDayHist] = React.useState(null); React.useEffect(() => { if (period !== 'dag' || histCovers || !window.hems?.getHistory) return; if (dayHist && dayHist.date === nav.from) return; const back = Math.round((ghDate(todayISO) - ghDate(nav.from)) / 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, todayISO, dayHist]); const histForDay = dayHist && dayHist.date === nav.from ? dayHist.data : history; /* Staplar och tabellrader ur samma dygn. */ const { bars, rows, cols, partialN } = React.useMemo(() => { if (period === 'dag'){ const hrs = window.svHoursFromHistory(histForDay, nav.from); /* Köppris per timme (backend 6/10, ekonomidygn v5): consumption[].buy_price_kr, kr/kWh med avgifter och moms. Bara dygn med prisdokument. */ const priceBy = {}; for (const r of (histForDay?.consumption || [])){ if (r.date === nav.from && r.hour >= 0 && r.hour < 24 && Number.isFinite(Number(r.buy_price_kr))) priceBy[r.hour] = Number(r.buy_price_kr); } const bars = hrs.map((h, i) => h ? { own: h.self, imp: h.imp, exp: h.exp, price: priceBy[i] ?? null, lbl: `kl ${String(i).padStart(2, '0')}–${String((i + 1) % 24).padStart(2, '0')}` } : { missing: true, price: priceBy[i] ?? null, lbl: `kl ${String(i).padStart(2, '0')}–${String((i + 1) % 24).padStart(2, '0')}` }); const hasPrice = Object.keys(priceBy).length > 0; const rows = hrs.map((h, i) => h ? { key: i, ord: i, d: String(i).padStart(2, '0'), prod: h.prod, own: h.self, imp: h.imp, exp: h.exp, price: hasPrice ? (priceBy[i] ?? null) : undefined } : null).filter(Boolean); return { bars, rows, cols: [['d', 'Kl'], ['prod', 'Sol', 1, 'kWh'], ['own', 'Egen el', 1, 'kWh'], ['imp', 'Köpt', 1, 'kWh'], ['exp', 'Sålt', 1, 'kWh'], ...(hasPrice ? [['price', 'Pris', 2, 'kr/kWh']] : [])], partialN: 0 }; } const byDate = {}; days.forEach(x => { byDate[x.date] = x; }); /* Krav 6 (v6): Kostade bredvid Betalt, och facitkolumnerna så LEN:s facit går att läsa av. Sparat mot elen du använde, plus resultatet av Sålt ur batteriet (8/10), när hela spannet bär den. */ const useUsed = days.length > 0 && days.every(x => x.used != null); const dayRow = (x) => ({ key: x.date, ord: x.date, d: ghDayLbl(x.date), cost: x.actual, used: x.used, grid: x.gridKr, el: x.elKrB != null ? x.elKrB : (x.gridKr != null && x.actual != null ? x.actual - x.gridKr : null), dlag: x.bgStart != null && x.bgEnd != null ? x.bgEnd - x.bgStart : null, bexp: x.bexpRes, batv: x.noBat != null && (x.hemsUsed ?? x.used) != null ? x.noBat - (x.hemsUsed ?? x.used) + (x.hemsRes ?? x.bexpRes ?? 0) : null, saved: x.nsnb != null ? (useUsed ? x.nsnb - x.used + (x.bexpRes ?? 0) : x.actual != null ? x.nsnb - x.actual : null) : null, prod: x.prod, imp: x.imp, exp: x.exp, inv: x.solEnd != null && x.gridEnd != null ? x.solEnd + x.gridEnd : null }); const mkCols = (rows) => { const v6 = rows.some(r => r.used != null), g = rows.some(r => r.grid != null); return [['d', period === 'ar' ? 'Månad' : 'Dag'], ['cost', 'Betalt', 2, 'kr'], ...(v6 ? [['used', 'Kostade', 2, 'kr']] : []), ...(g ? [['grid', 'Nät', 2, 'kr'], ['el', 'Elpris', 2, 'kr']] : []), ...(v6 ? [['dlag', 'Δ lager', 2, 'kr'], ['bexp', 'Sålt ur batt.', 2, 'kr'], ['batv', 'Batt. tillf.', 2, 'kr']] : []), ['saved', 'Sparat', 2, 'kr'], ['prod', 'Sol', 1, 'kWh'], ['imp', 'Köpt', 1, 'kWh'], ['exp', 'Sålt', 1, 'kWh'], ...(v6 ? [['inv', 'I batt.', 1, 'kWh', 'last']] : [])]; }; if (period === 'ar'){ const y = nav.date.getFullYear(), bars = [], rows = []; for (let m = 0; m < 12; m++){ const pre = y + '-' + String(m + 1).padStart(2, '0'); const ds = days.filter(x => x.date.startsWith(pre)); const g = ghAgg(ds, fee); bars.push(g ? { own: g.own, imp: g.imp, exp: g.exp, lbl: GH_MONTH[m] + ' ' + y } : { missing: true, lbl: GH_MONTH[m] + ' ' + y }); if (g) rows.push({ key: pre, ord: m, d: GH_MONTH[m], cost: g.actual, used: g.used, grid: g.gridSrc === 'backend' ? g.gridKr : null, el: g.gridSrc === 'backend' ? g.elKr : null, dlag: g.dLag, bexp: g.bx ? g.bx.res : null, batv: g.batD1, saved: g.saved, prod: g.prod, imp: g.imp, exp: g.exp, inv: g.inv ? g.inv.kwh : null }); } return { bars, rows, cols: mkCols(rows), partialN: days.filter(x => x.partial).length }; } const bars = []; for (let d = ghDate(nav.from); ghISO(d) <= nav.to; d.setDate(d.getDate() + 1)){ const k = ghISO(d), x = byDate[k]; bars.push(x ? { own: x.cons != null ? Math.max(0, x.cons - (x.imp || 0)) : null, imp: x.imp, exp: x.exp, partial: x.partial, date: k, lbl: ghDayLbl(k) } : { missing: true, date: k, lbl: ghDayLbl(k) }); } const rows = days.map(dayRow); return { bars, rows, cols: mkCols(rows), partialN: days.filter(x => x.partial).length }; }, [period, days, histForDay, nav.from, nav.to, fee]); React.useEffect(() => { let i = bars.length - 1; while (i > 0 && bars[i].missing) i--; setSel(Math.max(0, i)); }, [period, nav.from, bars.length, bars.filter(b => !b.missing).length]); const lbl = period === 'dag' ? (offset === 0 ? 'i dag' : offset === -1 ? 'i går' : ghDayLbl(nav.from)) : period === 'vecka' ? `vecka ${ghIsoWeek(nav.date)} · ${ghDate(nav.from).getDate()} ${GH_MON[ghDate(nav.from).getMonth()]}–${ghDate(nav.to).getDate()} ${GH_MON[ghDate(nav.to).getMonth()]}` : period === 'manad' ? `${GH_MONTH[nav.date.getMonth()]} ${nav.date.getFullYear()}` : String(nav.date.getFullYear()); const title = period === 'dag' ? nav.from : lbl; /* Periodvalets texter: huvudrad, underrad med datum och dygn med data, grannperiodernas korta namn och Till nu-knappens mål. */ const swipeX = React.useRef(null); const spanDays = Math.round((ghDate(nav.to) - ghDate(nav.from)) / 86400000) + 1; const cnt = wait ? '' : ` · ${days.length} av ${spanDays} dygn`; const fD = ghDate(nav.from), tD = ghDate(nav.to); const head = period === 'dag' ? { main: offset === 0 ? 'i dag' : offset === -1 ? 'i går' : ghDayLbl(nav.from), sub: `${fD.getDate()} ${GH_MONTH[fD.getMonth()]} ${fD.getFullYear()}` } : period === 'vecka' ? { main: `vecka ${ghIsoWeek(nav.date)}`, sub: `${fD.getDate()}${fD.getMonth() !== tD.getMonth() ? ' ' + GH_MON[fD.getMonth()] : ''}–${tD.getDate()} ${GH_MON[tD.getMonth()]}${cnt}` } : period === 'manad' ? { main: `${GH_MONTH[nav.date.getMonth()]} ${nav.date.getFullYear()}`, sub: `1–${tD.getDate()} ${GH_MON[tD.getMonth()]}${cnt}` } : { main: String(nav.date.getFullYear()), sub: cnt.replace(/^ · /, '') || ' ' }; const nb = (() => { if (period === 'dag'){ const p = new Date(nav.date), n = new Date(nav.date); p.setDate(p.getDate() - 1); n.setDate(n.getDate() + 1); return { prev: GH_WD[(p.getDay() + 6) % 7], next: GH_WD[(n.getDay() + 6) % 7] }; } if (period === 'vecka'){ const p = new Date(nav.date), n = new Date(nav.date); p.setDate(p.getDate() - 7); n.setDate(n.getDate() + 7); return { prev: 'v. ' + ghIsoWeek(p), next: 'v. ' + ghIsoWeek(n) }; } if (period === 'manad'){ const m = nav.date.getMonth(); return { prev: GH_MON[(m + 11) % 12], next: GH_MON[(m + 1) % 12] }; } const y = nav.date.getFullYear(); return { prev: String(y - 1), next: String(y + 1) }; })(); const nowTxt = period === 'dag' ? 'Till i dag' : period === 'vecka' ? `Till vecka ${ghIsoWeek(base)}` : period === 'manad' ? `Till ${GH_MONTH[base.getMonth()]}` : `Till ${base.getFullYear()}`; /* André 7/10: "När man scrollar ner så skall denna delen stanna i toppen ... sidan inte laddas om och hoppa upp". (a) En liten stegare fastnar under appens huvud (.gc-head, sticky top-0) när periodrutan rullat ur bild. (b) Medan nästa period hämtas står förra periodens innehåll kvar, nedtonat, i stället för en kort laddruta; sidan behåller sin höjd och rullar inte. */ const boxRef = React.useRef(null), lastContent = React.useRef(null); const [headH, setHeadH] = React.useState(0), [stuck, setStuck] = React.useState(false); React.useEffect(() => { const h = document.querySelector('.gc-head'); if (!h) return; const m = () => setHeadH(Math.ceil(h.getBoundingClientRect().height || 0)); m(); const ro = window.ResizeObserver ? new ResizeObserver(m) : null; if (ro) ro.observe(h); window.addEventListener('resize', m); return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', m); }; }, []); React.useEffect(() => { const el = boxRef.current; if (!el || !window.IntersectionObserver) return; const io = new IntersectionObserver(([e]) => setStuck(!e.isIntersecting && e.boundingClientRect.top < headH + 1), { rootMargin: `-${headH}px 0px 0px 0px`, threshold: 0 }); io.observe(el); return () => io.disconnect(); }, [headH]); const jump = (to, iso) => { const d = ghDate(iso), b0 = new Date(base); b0.setHours(0, 0, 0, 0); const off = to === 'dag' ? Math.round((d - b0) / 86400000) : to === 'vecka' ? Math.round((ghMonday(d) - ghMonday(b0)) / (7 * 86400000)) : (d.getFullYear() - b0.getFullYear()) * 12 + (d.getMonth() - b0.getMonth()); if (to === period) setOffset(off); else { jumpOff.current = off; setPeriod(to); } /* Upp till periodvalet så kunden ser vad som valts. Närmaste rullbara förälder, annars fönstret (inte scrollIntoView). */ requestAnimationFrame(() => { const el = rootRef.current; if (!el) return; let p = el.parentElement; while (p && !(p.scrollHeight > p.clientHeight && /(auto|scroll)/.test(getComputedStyle(p).overflowY))) p = p.parentElement; const top = el.getBoundingClientRect().top; if (p) p.scrollTo({ top: p.scrollTop + top - p.getBoundingClientRect().top - 8, behavior: 'smooth' }); else window.scrollTo({ top: window.scrollY + top - 8, behavior: 'smooth' }); }); }; const step = (dir) => { if (dir < 0 && -offset < maxBack) setOffset(o => o - 1); if (dir > 0 && offset < 0) setOffset(o => o + 1); }; return
{-offset < maxBack ? : }
{head.main}
{head.sub}
{offset < 0 ? : }
{/* Periodvalet, 2b + B (André 7/10, Historik-periodval-mockup-v3.html): periodtyp och stegare i samma ruta; pilarna bär grannperiodens namn, pilen framåt finns bara när det finns något framåt; stapeln visar hur många dygn i perioden som har data; svep i sidled byter period. Till nu sitter i rubrikraden och syns bara bakåt, så inget under flyttas. */}

Historik

{offset < 0 && }
{[['dag', 'Dag'], ['vecka', 'Vecka'], ['manad', 'Månad'], ['ar', 'År']].map(([id, txt]) => )}
{ swipeX.current = e.touches[0].clientX; }} onTouchEnd={(e) => { const dx = e.changedTouches[0].clientX - (swipeX.current ?? e.changedTouches[0].clientX); swipeX.current = null; if (dx > 50 && -offset < maxBack) setOffset(o => o - 1); else if (dx < -50 && offset < 0) setOffset(o => o + 1); }}> {-offset < maxBack ? : }
{head.main}
{head.sub}
{offset < 0 ? : }
{period !== 'dag' &&
}
{(() => { const content = x.date < y.date ? -1 : 1)} onJump={jump}/> {(period === 'dag' || !wait) && } {(period === 'dag' || !wait) && } ; if (!wait) lastContent.current = content; const stale = wait && !!lastContent.current && period !== 'dag'; return
{stale ? lastContent.current : content}
; })()}
; } Object.assign(window, { GcHistorik });