/* ============================================================================ GC · ÖVERSIKT — port av kollegans OversiktView mot riktig data ---------------------------------------------------------------------------- Markup och klasser är hans. Tre skillnader, alla av samma skäl (hans fil räknar med ett hushåll som inte är det här): • Noderna följer enhetslistan — ett batteri och en laddbox, inte 3 + 2. • Effekt som backend inte skickar visas som "—", inte som 0,0 kW. • Laddboxens källa per planerad kvart finns inte i kontraktet (bara live), så plantexten säger när bilen laddas, inte varifrån. ========================================================================== */ const { useMemo: gcoMemo } = React; /* Prelim-violetten är forecast.jsx:s (rgba(201,160,255,.82)) — morgondagens osäkra zon har redan en färg i huset, den ska inte få en andra. */ const GC_PRELIM = 'rgba(201,160,255,.82)'; function GcOversikt({ day, tomorrow, units, nowIdx, live, tomorrowPrelim, goSchema }){ /* Guarden får INTE ligga före useMemo nedan: buildGravioDay kan returnera en tom array (loopen bryter när en kvart saknas), och då skulle komponenten rendera med noll hooks och krascha nästa render som har data. */ const s = (day && day.length) ? day[Math.min(nowIdx, day.length-1)] : null; const mode = window.gcMode(s && s.mode); const MONO = window.GC_MONO, LABEL = window.GC_LABEL; const svNum = window.gcNum, qLabel = window.gcLabel; const mkPlan = (arr) => { if (!s) return null; const R = (pred, smooth) => window.gcRanges(arr, pred, smooth); const discharging = (x) => x.bat.some((b) => b.p != null && b.p < -0.05); const charges = (src) => (x) => x.bat.some((b) => b.p != null && b.p > 0.05 && b.src === src); const gq = arr.filter(charges('nät')); // Hans "snittspot" — men backendens buy är INKLUSIVE avgifter, så ordet // spot vore fel här. const gridAvg = gq.length ? Math.round(gq.reduce((a,x) => a + (x.buy||0), 0) / gq.length) : 0; return { gridAvg, ev: units.evs.map((_, k) => R((x) => (x.ev[k]?.p || 0) > 0.05, false)), loads: units.loads.map((_, k) => R((x) => (x.loads[k]?.p || 0) > 0.05, false)), loadSrc: units.loads.map((_, k) => arr.find((x) => x.loads[k]?.src)?.loads[k]?.src || null), solCh: R(charges('sol'), false), gridCh: R(charges('nät'), false), ...(() => { const cls = arr.map((x) => { if (x.sol == null || x.cons == null) return null; if (x.sol >= x.cons - 0.05) return 'sol'; if (discharging(x)) return 'bat'; if (charges('nät')(x)) return null; return 'grid'; }); const is = (k) => (x, i) => cls[i] === k; const Rc = (k) => window.gcRanges(arr, (x, i) => is(k)(x, i), false); return { solCov: Rc('sol'), batCov: Rc('bat'), neither: Rc('grid') }; })(), }; }; const plan = gcoMemo(() => mkPlan(day), [day, units, s]); /* Listan får sträcka sig in i nästa dygn: sent på kvällen finns bara ett spann kvar av i dag, och kortet visade då en enda rad. Spann som passerar midnatt blir EN rad tack vare sammanslagningen — därför konkateneras arrayerna i stället för att beräknas var för sig. */ const planSeq = gcoMemo(() => mkPlan(tomorrow && tomorrow.length ? day.concat(tomorrow) : day), [day, tomorrow, units, s]); /* En rad per sammanhängande spann, alla beslut i samma lista, sorterad på starttid. Färgen är beslutets, samma som i graferna: sol gul, nät blå, batteri grönt. Summan per beslut räknas ur samma rader, så listan och summaraden aldrig kan säga olika. Två SPÅR, inte ett. Vem som täcker huset är ett beslut som delar dygnet i ömsesidigt uteslutande spann; laddning av batteri, laster och bilar är beslut som löper INUTI ett sådant spann. En platt kronologisk lista läste som att 08:00–19:00 "Solen täcker huset" och 08:30–08:45 "Batteriet laddas av sol" var två order i följd som krockade (Lennie 20/8). Därför bär varje rad sitt spår, och raderna i det underordnade spåret dras in under det spann de ligger inuti. */ const timeline = gcoMemo(() => { if (!plan || !planSeq) return null; const SRC = { sol:'#facc15', 'nät':'#60a5fa', batteri:'#fb923c' }; const rowsOf = (plan, arr) => { const rows = []; /* PRIS PER BESLUT (André 29/8): "säljpris vid export och köppris när batteriet laddar från nätet eller täcker huset". Snittet räknas ur SPANNETS egna kvartar — ett dygnssnitt hade sagt något annat än vad raden gäller. Tre fall, tre olika betydelser: buy nätet köps in (nätet täcker huset, batteriet laddas från nätet) sell solöverskott matas ut — bara kvartar där sol MINUS förbrukning MINUS batteriladdning faktiskt är positivt; annars går solen in i huset eller batteriet och inget säljs. avoid batteriet täcker huset: priset som INTE betalas. Måste märkas, annars läses talet som en kostnad. Preliminära morgondagspriser hoppas: gcPriceUnknown gäller här som i grafen — ett tal ur en proxy är en gissning i faktatonläge. */ const priceOf = (a, b, kind) => { const q = []; for (let i = a; i < b && i < arr.length; i++){ const x = arr[i]; if (!x || window.gcPriceUnknown(tomorrowPrelim, i)) continue; if (kind === 'sell'){ const chg = (x.bat || []).reduce((t, bb) => t + (bb.p > 0 ? bb.p : 0), 0); if ((x.sol || 0) - (x.cons || 0) - chg <= 0.05 || x.sell == null) continue; q.push(x.sell); } else { if (x.buy == null) continue; q.push(x.buy); } } return q.length ? { v: Math.round(q.reduce((t,v) => t+v, 0)/q.length), kind } : null; }; const add = (rs, track, label, col, note, priceKind) => (rs || []).forEach(([a,b]) => rows.push({ a, b, track, label, col, note, price: priceKind ? priceOf(a, b, priceKind) : null })); add(plan.solCh, 'bat', (s.bat.length > 1 ? 'Batterierna laddas' : 'Batteriet laddas') + ' av sol', '#facc15'); add(plan.gridCh, 'bat', (s.bat.length > 1 ? 'Batterierna laddas' : 'Batteriet laddas') + ' från nätet', '#60a5fa', null, 'buy'); add(plan.solCov, 'hus', 'Solen täcker huset', '#facc15', null, 'sell'); add(plan.batCov, 'hus', (s.bat.length > 1 ? 'Batterierna täcker' : 'Batteriet täcker') + ' huset', '#4ade80', null, 'avoid'); add(plan.neither, 'hus', 'Nätet täcker huset', '#60a5fa', null, 'buy'); plan.loads.forEach((rs, k) => add(rs, 'last', units.loads[k].name + ' körs', SRC[plan.loadSrc[k]] || '#22d3ee', plan.loadSrc[k])); plan.ev.forEach((rs, k) => add(rs, 'ev', units.evs[k].name + ' laddas', '#a78bfa')); /* Husspåret först vid samma starttid — det underordnade spannet ska aldrig stå ovanför det spann det ligger inuti. */ rows.sort((x, y) => x.a - y.a || (x.track === 'hus' ? -1 : y.track === 'hus' ? 1 : 0) || x.b - y.b); /* Indrag bara när ett husspann faktiskt omsluter raden. Nattlig nätladdning ligger utanför husspåret (den kvarten har inget täckningsbeslut) och står därför kvar i vänsterkanten i stället för att peka på ingenting. */ const cov = rows.filter((r) => r.track === 'hus'); rows.forEach((r) => { r.sub = r.track !== 'hus' && cov.some((h) => h.a <= r.a && h.b >= r.b); }); return rows; }; /* Chipsen räknas ur DYGNET, listan ur dygnet + morgondagen. Annars skulle "Batteriet täcker huset 12 h" bli 24 h så fort morgondagen fanns. */ const today = rowsOf(plan, day); const seq = rowsOf(planSeq, tomorrow && tomorrow.length ? day.concat(tomorrow) : day); const tot = new Map(); today.forEach(r => tot.set(r.label, (tot.get(r.label) || 0) + (r.b - r.a))); /* Framåt = spann som inte är slut. Den pågående räknas som framåt även om den började i morse; annars försvinner det som gäller just nu. */ const ahead = seq.filter((r) => r.b > nowIdx); return { rows: seq, past: seq.filter((r) => r.b <= nowIdx).length, ahead: ahead.slice(0, 5), totals: [...tot.entries()] .map(([label, q]) => ({ label, q, col: today.find(r => r.label === label).col })) .sort((a, b) => b.q - a.q) }; }, [plan, planSeq, units, s, nowIdx, day, tomorrow, tomorrowPrelim]); /* Kvartar → "6 h 15 min". Timmar utan minuter skrivs utan "0 min". */ /* Hårda mellanslag: "5 h 18 min" radbröts mitt i (backend 31/8). */ const dur = (q) => { const h = Math.floor(q/4), m = (q % 4) * 15; return h && m ? h + '\u00A0h\u00A0' + m + '\u00A0min' : h ? h + '\u00A0h' : m + '\u00A0min'; }; /* Texten i flödet ligger som SVG-attribut i användarkoordinater, medan viewBox-bredden är konstant. På en telefon skalas hela bilden till ~0,50 och statusraderna renderade 5 px — oläsligt. Mät kolumnen och höj varje textklass till sitt eget golv i FAKTISKA px när skalan kryper ner. En CSS-media-query duger inte: storlekarna är attribut, inte CSS. */ const [sc, setSc] = React.useState(1); /* CALLBACK-REF, inte useEffect. Komponenten returnerar null tills schemat finns (day kan vara en tom array — truthy, så gc-app monterar oss ändå), och en effekt med tom deps-array hann då köra utan SVG att observera och kördes aldrig om. Skalan fastnade på 1 och telefonen fick 5 px text — precis felet fixen skulle laga. En callback-ref anropas när noden faktiskt monteras, hur sent det än sker, och skalan sätts direkt så första målningen aldrig visar för liten text. */ const roRef = React.useRef(null); const svgRef = React.useCallback((node) => { if (roRef.current) { roRef.current.disconnect(); roRef.current = null; } if (!node || typeof ResizeObserver === 'undefined') return; const upd = () => { const w = node.clientWidth || node.getBoundingClientRect().width; if (w > 0) setSc(w / 640); }; const ro = new ResizeObserver(upd); ro.observe(node); roRef.current = ro; upd(); }, []); const fs = (base, minPx) => Math.round(Math.max(base, minPx / Math.max(sc, 0.2)) * 10) / 10; /* Radavstånd följer storleken, annars klipper raderna i varandra så fort texten växer. Faktorerna är valda så att desktop ger exakt hans värden (-7, +14, +r+17, +r+32). */ const FS = { val: fs(24,15), unit: fs(11,8), name: fs(14,11), stat: fs(10,8.5) }; const Y = { val: FS.val*0.02, unit: FS.val*0.58 + FS.unit*0.40, name: FS.name*1.2, stat: FS.name*1.2 + FS.stat*1.45, }; /* Ikon över talet, andra raden under enheten. Offsets i ANDELAR av radien, så glyfen aldrig kan hamna utanför orbet oavsett nodstorlek eller skala. */ /* 24-enhetsrutan i bits.jsx ⇒ glyfen är 24·s bred. 0,72 ger 17 px vid r=54, samma optiska storlek som gamla designens ikoner. */ const ICON = { dy: (r) => -r*0.52, s: (r) => (r/54)*0.72 }; /* Styrcentralen rymmer tre rader på desktop men inte på telefon: orbet är 62 enheter i radie oavsett skärm. Under 0,75 skala visas bara läget — "HEMS" och "styrcentral" är dekor, läget är informationen. */ const tight = sc < 0.75; /* Ryms effektraden inne i ringen? Samma prov som renderingen; används för att skjuta ner namn/status när raden faller ut under ringen. */ const subOut = (n) => { if (!n.sub) return false; const chars = String(n.sub).length; for (let step = 0; step < 10; step++){ const f2 = FS.unit*1.15*Math.pow(0.94, step); if (f2 < FS.unit*0.72) break; let y2 = Y.unit + f2*1.4; for (let k = 0; k < 20 && y2 > FS.val*0.6; k++){ const bottom = y2 + f2*0.55; const chord = bottom < n.r ? Math.sqrt(n.r*n.r - bottom*bottom) : 0; if (chars*f2*0.31 <= chord - 2) return false; y2 -= 1.5; } } return true; }; if (!s || !plan) return null; const T = ({ rs }) => ( {rs.map(([a,b]) => `${qLabel(a)}–${qLabel(b % window.GC_Q)}`).join(', ')} ); /* Hur gammalt mätvärdet är, räknat ur `timestamp` mot klockan — ALDRIG ur `age_sec`. Backend bekräftade mekanismen 24/8: sensor_live skrivs varje ping (~20 s), men /api/live-data cachar HELA svaret i 150 s server-side. age_sec räknas rätt när cachen skrivs och serveras sedan fruset — uppmätt 71,6 s när verkliga åldern var 209,3 s (största fel 137,7 s). Cachen ligger hos servern, därför bet inte cache: no-store. Backend fixar cachen; den här raden ligger kvar även efter det, för timestamp mot klockan är mer korrekt än ett fält som räknas vid skrivning. */ const liveStamp = (() => { const ts = live && live.timestamp ? Date.parse(live.timestamp) : NaN; if (!ts) return null; const nowMs = (window.hemsDate ? window.hemsDate() : new Date()).getTime(); const sec = Math.max(0, Math.round((nowMs - ts)/1000)); const clock = new Date(ts).toLocaleTimeString("sv-SE"); if (sec <= 45) return { txt: "mätt " + clock, col:"#64748b" }; const alder = sec < 90 ? sec + " s" : Math.round(sec/60) + " min"; /* Åldern FÖRST: på 360 px kapades raden mitt i "3 mi…" när klockslaget stod först, och åldern är hela poängen med stämpeln. */ return { txt: alder + " gammalt · mätt " + clock, col: sec >= 180 ? "#fb923c" : "#94a3b8" }; })(); /* IIFE, INTE useMemo: raden ligger efter guarden if (!s || !plan) och ett hook där hade varit villkorligt — samma hook-ordningskrasch som kommentaren högst upp i filen varnar för. */ const C = window.GC_C; const L = window.gcLayout(s.bat.length, s.ev.length); const nodes = [ { id:'sol', ...L.sol, ring:'#eab308', icon:'sol', val: svNum(s.sol), unit:'kW', name:'Sol', status: s.sol == null ? 'okänt' : s.sol > 0.05 ? 'producerar' : 'vilar', active: (s.sol||0) > 0.05, lineCol:'#eab308', dir:'in' }, { id:'hem', ...L.hem, ring:'#94a3b8', icon:'home', val: svNum(s.cons), unit:'kW', name:'Hemmet', status:'förbrukar', active:true, lineCol:'#94a3b8', dir:'out' }, ...s.bat.map((bp, k) => ({ id:'b'+k, ...L.bats[k], ring: units.bats[k]?.aiControlled ? '#a78bfa' : '#4ade80', icon:'bat', /* SoC som huvudtal OCH effekt som andra rad — batteriet är den enda noden som bär två storheter, och båda behövs: nivån svarar "hur mycket finns kvar", effekten "vad gör det just nu". Ett försök att göra effekten till huvudtal tappade nivån helt. Tecknet följer gamla designen: minus = lämnar batteriet. */ val: s.socPct?.[k] != null ? String(Math.round(s.socPct[k])) : '—', unit:'%', sub: bp.p == null || Math.abs(bp.p) <= 0.05 ? null : ((bp.p > 0 ? '+' : '−') + svNum(Math.abs(bp.p), 1) + ' kW'), subCol: (bp.p||0) > 0 ? '#4ade80' : '#7dd3fc', name: window.gcBatName(units, bp, k), status: window.gcBatStatus(bp), active: bp.p != null && Math.abs(bp.p) > 0.05, lineCol: units.bats[k]?.aiControlled ? '#a78bfa' : '#4ade80', dir: (bp.p||0) > 0 ? 'out' : 'in' })), ...s.ev.map((e, k) => { /* V2X: `ev_power` är teckenbärande hela vägen från sensorn (backend 2026-08-19). Negativ effekt = bilen MATAR huset/husbatteriet, och då är laddboxen en källa precis som solen — samma nod, motsatt riktning. Tidigare krävde både `active` och statusraden p > 0,05, så en inmatande box ritades släckt med ett stillastående negativt tal. Beloppet visas som absolutvärde — riktningen bärs av animationen, inte av ett minustecken i orbet. Hur inmatningen fördelas mellan hus och batteri finns inte mätt (samma läge som solens fördelning); navet fördelar vidare via husets och batteriets egna ekrar, så noden lovar inget den inte vet. */ const feed = e.p != null && e.p < -0.05, chg = (e.p||0) > 0.05; const col = feed ? '#fb923c' : (window.GC_EV_SRC_COL[e.src] || '#60a5fa'); return { id:'ev'+k, ...L.evs[k], icon:'ev', ring: (chg || feed) ? col : '#475569', /* Tecknet följer med när bilen matar (foto 27/8) — samma regel som batterinoden: minus = effekten lämnar enheten. */ val: e.p == null ? svNum(null) : (feed ? '−' : '') + svNum(Math.abs(e.p)), unit:'kW', name: e.name || units.evs[k]?.name || 'Laddbox', status: feed ? 'matar huset' : chg ? (e.src ? `laddar · ${e.src}` : 'laddar') : 'ingen laddning', active: chg || feed, lineCol: col, dir: feed ? 'in' : 'out' }; }), { id:'grid', ...L.grid, ring:'#60a5fa', icon:'grid', val: svNum(s.gridOut > 0.05 ? s.gridOut : s.gridIn), unit:'kW', name:'Elnät', status: s.gridOut > 0.05 ? 'säljer' : s.gridIn > 0.05 ? 'köper' : 'vilar', active: s.gridOut > 0.05 || s.gridIn > 0.05, lineCol: s.gridOut > 0.05 ? '#4ade80' : '#60a5fa', dir: s.gridOut > 0.05 ? 'out' : 'in' }, ]; /* "Täck huset" med VILANDE batterier och sol ≥ förbrukning betyder i praktiken att solen täcker huset (kollegan 27/8, foto 16:47: alla batterier 0,00 kW, sol 13,4, nätet säljer — men texten sa "batterierna driver huset"). Beskrivningen följer det som faktiskt sker, inte kommandots ordalydelse. Gäller bara FALLBACKEN: en riktig reason-text från backend vinner som förut. */ const _batIdle = s.bat.length > 0 && s.bat.every((b) => b.p == null || Math.abs(b.p) < 0.05); const _solCovers = (s.sol || 0) > 0.1 && (s.sol || 0) >= (s.cons || 0) - 0.05; /* Beskrivningen gäller KOMMANDOT. Vad som faktiskt händer står i verklighetsraden nedanför (André 29/8) — den första SOC≥97-fallbacken här revs samma dag: att byta ut kommandots beskrivning var fel lager, verklig- heten ska stå bredvid kommandot, inte ersätta det. Sol-täcker-fallbacken är kvar: den rättar en direkt FELAKTIG mening (batterierna "driver huset" när de vilar), inte en sann. */ const _socPct = s.socAggPct ?? null; const modeDesc = (s.mode === 'battery' && _batIdle && _solCovers) ? 'Solen täcker förbrukningen — batterierna vilar.' : mode.desc; return (
HEMS beslut just nu
{mode.label}

{window.gcReason(s.reason, modeDesc)}

{/* VERKLIGHETSRADEN (André 29/8): "kommandot är x men också vad som händer i verkligheten — är det vänta men batteriet laddas vill jag se det, samma om det uppstår export". Kommandot ovanför är HEMS avsikt; den här raden är MÄTVÄRDENA, alltid synlig, aldrig härledd ur kommandot. Skiljer de sig är det just det som ska synas — ingen maskering, ingen tolkning. Delar utan mätvärde utelämnas. */} {(() => { const parts = []; const many = s.bat.length > 1; const batNet = s.bat.every((b) => b.p != null) ? s.bat.reduce((t, b) => t + b.p, 0) : null; if (batNet != null) parts.push( batNet > 0.05 ? `${many ? 'batterierna laddar' : 'batteriet laddar'} ${svNum(batNet)} kW` : batNet < -0.05 ? `${many ? 'batterierna laddar ur' : 'batteriet laddar ur'} ${svNum(-batNet)} kW` : `${many ? 'batterierna vilar' : 'batteriet vilar'}${_socPct != null ? ` (${Math.round(_socPct)} %)` : ''}`); if (s.gridOut > 0.05) parts.push(`säljer ${svNum(s.gridOut)} kW till nätet`); else if (s.gridIn > 0.05) parts.push(`köper ${svNum(s.gridIn)} kW från nätet`); if ((s.sol || 0) > 0.05) parts.push(`sol ${svNum(s.sol)} kW`); if (!parts.length) return null; return (

Sker nu · {parts.join(' · ')}

); })()}
Rekommenderad effekt
{/* Kommandot, inte mätvärdet. Är de olika visas mätvärdet under — då följer något annat än HEMS batteriet just nu (manuell urladdning, extern AI, växelriktarens egna gränser). */} {(() => { /* Aggregat och per enhet får inte blandas i samma rad — samma regel som för SoC. Kommando och mätvärde har VAR SITT scope, satt av olika fält: `per_battery_actions` styr kommandots, `battery_power_list` mätvärdets. Alla fyra kombinationer finns, och båda glappen ljuger på samma sätt om de läses rakt: bankens tal mot en enhets tal triggar "uppmätt just nu" på ren skalskillnad och påstår en manuell override som inte finns. Därför: jämför bara inom samma scope, annars på summanivå. */ const cmds = s.batCmd || s.bat; const cmdAgg = cmds.length === 1 && !!cmds[0].aggregate && s.bat.length > 1; const measAgg = s.bat.length === 1 && !!s.bat[0].aggregate && cmds.length > 1; const kw = (v) => `${v >= 0 ? '' : '−'}${svNum(Math.abs(v), 2)} kW`; const sum = (arr) => arr.every((x) => x.p != null) ? arr.reduce((a, x) => a + x.p, 0) : null; const measOf = (k) => cmdAgg ? sum(s.bat) : measAgg ? null : (s.bat[k]?.p ?? (s.bat.length === 1 ? s.bat[0]?.p : null)); /* N kommandon mot ETT uppmätt banktal: raderna namnges ur enhetslistan (aldrig ur s.bat[0], som är banken) och avvikelsen visas EN gång, summa mot summa. */ const cmdSum = measAgg ? sum(cmds.filter((c) => !c.ai)) : null; const bankDev = measAgg && cmdSum != null && s.bat[0].p != null && Math.abs(s.bat[0].p - cmdSum) > 0.3; return ( {cmds.map((c, k) => { const measP = measOf(k); const dev = c.p != null && measP != null && Math.abs(measP - c.p) > 0.3; return (
{window.gcBatName(units, (!cmdAgg && !measAgg && s.bat[k]) ? s.bat[k] : c, k)} {' · '}{c.ai ? 'AI-styrt' : window.gcBatStatus(c)} {c.p == null ? '—' : kw(c.p)}
{dev && (
uppmätt just nu{cmdAgg ? ' · summa' : ''} {kw(measP)}
)}
); })} {bankDev && (
uppmätt just nu · hela banken {kw(s.bat[0].p)}
)}
); })()} {s.ev.map((e, k) => (
{e.name || units.evs[k]?.name} · {e.p != null && e.p < -0.05 ? 'matar huset' : (e.p||0) > 0.05 ? (e.src ? `laddar ${e.src}` : 'laddar') : 'ingen laddning'} {Math.abs(e.p || 0) > 0.05 && · uppmätt} {/* Tecknet följer batteriraden ovan: minus = energi lämnar bilen. */} {e.p == null ? '—' : `${e.p >= 0 ? '' : '−'}${svNum(Math.abs(e.p), 2)} kW`}
))} {/* Skjutbara laster (2/9). Tre fall, i fallande sanning: 1. measured — backend exponerar load_power_list i /live (BACKEND_BEHOV §10): uppmätt värde, vit/fet som EV-raderna. 2. rapporterande last utan fältet: INGEN siffra (—) — planens 2 kW lästes som mätning hur nedtonad den än var (kollegan 2/9: "den är igång men värdet är fel"). 3. ej rapporterande: angiven effekt, nedtonad. */} {(s.loads || []).map((x, k) => { const meas = x.measured === true; const on = x.p != null && x.p > 0.05; const rep = units.loads[k]?.reportsPower; return (
{units.loads[k]?.name || 'Last ' + (k+1)} · {on ? `körs${x.src ? ' ' + x.src : ''}` : 'körs inte nu'} {meas ? · uppmätt : on && · {rep ? 'uppmätt effekt visas ej ännu' : 'angiven effekt'}} {meas ? {svNum(x.p, 2)} kW : {!on ? '0,00 kW' : rep ? '—' : `${svNum(x.p, 2)} kW`}}
); })}
{/* Backendens text ordagrant (31/8), ALLTID synlig: förklarar varför uppmätt kan avvika från kommandot mellan uppdateringarna — utan den läses varje glapp som ett fel. */}

HEMS skickar kommandon och effektnivåer var 5:e minut. Mellan uppdateringarna kan förbrukningen hinna ändras — batteriet täcker då husets last enligt senaste beräkningen, inte sömlöst i realtid. Korta avvikelser (t.ex. när en spis slås på eller en laddning rampar) är normala och jämnas ut vid nästa uppdatering.

⚡ Energiflöde just nu {mode.label}
{liveStamp && (
{liveStamp.txt}
)} {/* viewBox KLIPPT till innehållet (André 27/8: "det finns ju utrymme i fyrkanten"). Nav 320,300 med orbit 200 och nodradie ≤ 54 ⇒ ringarna lever i x 66–574, y 46–586; de gamla 0–640 × 0–615 gav ~11 % dött marginal på varje sida. Samma bild ~1,2× större i faktiska pixlar på ALLA skärmar — vilket är den riktiga fixen för texten i ringarna: fs()-golvet slår till senare när skalan är högre, så effektraden behöver sällan klampas alls. Koordinaterna är orörda. */} {nodes.map((n) => ( {n.active && ( )} ))} {nodes.filter((n) => n.active).map((n) => { const path = n.dir === 'in' ? `M${n.x},${n.y} L${C.x},${C.y}` : `M${C.x},${C.y} L${n.x},${n.y}`; return ( ); })} {nodes.map((n) => ( {n.active && ( )} {n.val} {n.unit} {/* Effektraden klampas mot RINGENS GEOMETRI (foto 27/8: "+12,8 kW" skar genom kanten på mobil). Felet var att fs() höjer teckengraden när kortet krymper (minPx/sc) medan noderna har FAST radie i viewBox: vid 360 px blev raden ~16 enheter och låg ~42 under centrum, där cirkelns halva korda bara är ~21. Desktop rymdes av tur — därför syntes det bara på mobil. Nu provas grader nedåt tills halva textbredden ryms i kordan vid radens underkant; går det inte ens vid golvet flyttas raden uppåt mot centrum, där cirkeln är bredast. */} {n.sub && (() => { /* Raden ska antingen rymmas INNE i ringen eller falla UT under den — aldrig renderas skärande. Inne: sök nedåt i både teckengrad och y-läge (utan att klampa tillbaka y, som förra loopen gjorde: den gav 1,5 enheter per varv och tog aldrig slut i tid). Utanför: raden hamnar mellan ring och nodnamn, och namn/status skjuts ner via subOut. Utfall vid r 48 med "−14,99 kW" och sc ≈ 0,47 (320 px-telefon) var 42 mot 36 enheter — det är fallet som tvingar utflytten. */ const chars = String(n.sub).length; const fits = (fs, y) => { const bottom = y + fs*0.55; const chord = bottom < n.r ? Math.sqrt(n.r*n.r - bottom*bottom) : 0; return chars*fs*0.31 <= chord - 2; }; let fs = FS.unit*1.15, y = Y.unit + fs*1.4, ok = false; for (let step = 0; step < 10 && !ok; step++){ const f2 = fs*Math.pow(0.94, step); if (f2 < FS.unit*0.72) break; let y2 = Y.unit + f2*1.4; for (let k = 0; k < 20 && y2 > FS.val*0.6; k++){ if (fits(f2, y2)){ fs = f2; y = y2; ok = true; break; } y2 -= 1.5; } } if (ok) return ( {n.sub} ); return ( {n.sub} ); })()} {n.name} {n.status.toUpperCase()} ))} {!tight && HEMS} {mode.label} {!tight && styrcentral}
Dagens plan
{timeline && timeline.past > 0 ? 'härifrån och framåt' : '00–24'}
{timeline.rows.length === 0 ? (

Ingen plan för dygnet ännu.

) : timeline.ahead.length === 0 ? (

Dygnets plan är genomförd.

) : (
{timeline.ahead.map((r, i) => (
{qLabel(r.a % window.GC_Q)}–{qLabel(r.b % window.GC_Q)} {r.a <= nowIdx && r.b > nowIdx && ( NU )} {r.a >= window.GC_Q && ( IMORGON )} {/* Ihålig ruta = beslutet vilar på ett pris som inte är släppt. Ingen extra text i raden: på 360 px äter en "preliminärt"-tagg upp etiketten. IMORGON-taggen byter färg i stället och kopplar raden till noten under listan. */} = window.GC_Q ? {border:'1.5px solid '+r.col} : {background:r.col}}/> {r.label} {r.note && {r.note}} {/* Priset som CHIP med användarens egna ord — "säljpris", "köppris" (Andrés formulering av önskemålet), "sparar" för batteriets undvikna köp. Tre varv föll före detta: "undviks" doldes på mobil, ↓/↑/✓ krävde legend, och "säljs/slipper/köps" i lös text lästes inte som en enhet. Chipet ramar in ord+tal+enhet som ETT värde (samma form som Hela dygnet-chipsen under), och substantiven är otvetydiga utan förkunskap. */} {r.price && ( {r.price.kind === 'sell' ? 'säljpris' : r.price.kind === 'avoid' ? 'sparar' : 'köppris'} {r.price.v} öre )} {dur(r.b - r.a)}
))}
{/* Hur stor del av dygnet varje beslut gäller. Räknas ur raderna ovan, aldrig ur en egen summering. */} {timeline.past > 0 && (

{timeline.past} passerade i dag

)} {/* Morgondagens rader vilar på dagens priser som proxy tills Nord Pool släppt nästa dygn (~13:00). Utan den här noten läses "Nätet täcker huset 00–08" som ett avgjort beslut — André 24/8. Samma flagga och samma ord som forecast.jsx: aldrig "spot", "proxy" eller "fallback" i UI, bara "elpriset" och "preliminärt". */} {tomorrowPrelim && timeline.ahead.some((r) => r.a >= window.GC_Q) && (

Imorgon är preliminärt — elpriset släpps runt kl 13:00, planen uppdateras då.

)}
Hela dygnet {timeline.totals.map((t) => ( {t.label} {dur(t.q)} ))}
)} {plan.ev.every((rs) => rs.length === 0) && units.evs.length > 0 && (

{units.evs.length > 1 ? 'Ingen bil har planerad laddning i dag.' : 'Ingen laddning planerad i dag.'}

)}
); } /* ── GcPeak — effektavgiften i nya designen (backend 31/8) ────────────────── Visas BARA när peak shaving är aktiverat (gaten ligger i gc-app). Översikt, inte åtgärd: timvarningen bor i bannern högst upp. Datakälla oförändrad: /api/peak-insights; toppen är alltid en AVSLUTAD timme (flaggan HEMS_PEAK_TOP_EXCLUDE_CURRENT), pågående timme separat och preliminär. */ function GcPeak({ peak }){ const MONO = window.GC_MONO, num = window.gcNum; const lim = peak?.peak_shaving?.max_grid_kw; const mp = peak?.monthly_peak; const sres = peak?.peak_shaving?.soc_reserve; const reserve = sres?.percent || 0; if (lim == null && mp?.max_hourly_avg_kw == null) return null; const row = (label, value, dim, title) => (
{label} {value}
); return (
Effektavgift {window.GcBeta && } {/* "timsnitt": gränsen och topparna är energi per timme (kWh/h) — korta effektspikar är ofarliga så länge timsumman håller. */} timsnitt, kWh/h
{lim != null && row('Gräns', num(lim, 1) + ' kWh/h')} {/* "(import)": monthly_peak.basis säger vad toppen mäts på — nät- import eller totalförbrukning (kontrakt 1/9 punkt C). Diskret, bara när backend skickar basis. */} {mp?.max_hourly_avg_kw != null && row( 'Högsta topp i månaden' + (mp.basis === 'import' ? ' (import)' : ''), /* kWh/h, inte kW (André 3/9). Effektavgiften mäter ENERGI över en timme, inte momentan effekt — samma storhet som gränsen och som kortrubriken "timsnitt, kWh/h". Att skriva kW på just de två värderaderna gjorde att kortet mätte två olika saker. */ num(mp.max_hourly_avg_kw, 2) + ' kWh/h' + (mp.timestamp ? ' · ' + new Date(mp.timestamp).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) : ''), false, 'Alltid en färdig timme — pågående timmen räknas inte in')} {mp?.current_hour_avg_kw != null && row('Pågående timme', num(mp.current_hour_avg_kw, 2) + ' kWh/h (preliminärt)', true)} {/* Reservkälla (kontrakt 1/9 punkt A): source säger om reserven är användarens egen (peak_reserve_kwh satt) eller automatisk. */} {reserve > 0 && row('SOC-reserv', num(reserve, 0) + ' % hålls för toppar' + (sres?.source === 'user' ? ' · vald av dig' : sres?.source === 'auto' ? ' · automatisk' : ''), true)} {/* Överdrag (kontrakt 1/9 punkt B): fälten finns bara med backend- flaggan på — saknas de renderas ingenting av detta. 0 är ett POSITIVT besked och skrivs ut; klartexten per reason gör senaste överdraget till en åtgärd, inte bara en siffra. */} {typeof peak?.month_overruns === 'number' && (
Överdrag denna månad {peak.month_overruns === 0 ? 'Inga överdrag denna månad' : peak.month_overruns + (peak.month_overruns === 1 ? ' timme' : ' timmar')}
)} {peak?.last_overrun && (() => { const lo = peak.last_overrun; /* hour_key "YYYY-MM-DD_HH" är lokal Stockholmstid — säkrare än att timezone-tolka timestamp i webbläsarens zon. */ const hk = typeof lo.hour_key === 'string' ? lo.hour_key.split('_') : null; const day = hk ? new Date(hk[0] + 'T12:00:00').toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) : lo.timestamp ? new Date(lo.timestamp).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) : null; const hh = hk ? hk[1] : null; const when = day ? `Senast: ${day}${hh ? ' kl ' + hh : ''} — ` : 'Senast: '; const kwh = `${num(lo.import_kwh, 1)} av ${num(lo.limit_kw, 1)} kWh`; const txt = lo.reason === 'reserve_empty' ? 'gränsen överskreds för att reserven var slut. Öka reserven eller gränsen.' : lo.reason === 'limit_too_low' ? `huset drog ensamt mer än gränsen (${kwh}). Gränsen är för låg.` : `${kwh}. Oväntad topp som skyddet inte hann med.`; return (
{when + txt}
); })()}
{/* Notisen NEDERST i kortet: talen ovanför är det man kom för, förbehållet är vad man tar med sig därifrån. */} {window.GcBetaNote && (
Effektavgiftshanteringen är ny och ännu inte färdigtestad av oss. Skyddet kan missa en topp, och talen här kan avvika från nätfakturan. Behåll egen marginal till abonnemangets gräns.
)}
); } Object.assign(window, { GcOversikt, GcPeak });