/* ============================================================================ 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 (
{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(' · ')}
); })()}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.
Ingen plan för dygnet ännu.
) : timeline.ahead.length === 0 ? (Dygnets plan är genomförd.
) : ({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å.
)}{units.evs.length > 1 ? 'Ingen bil har planerad laddning i dag.' : 'Ingen laddning planerad i dag.'}
)}