/* ============================================================================ 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)'; const GcFold = window.GcFold; // gc-core.jsx, laddas före denna fil const GCO_IDLE_KW = window.GC_IDLE_KW || 0.15; // tomgångsband för statustext (gc-core, v592) /* Fasfärg (v594, mockup v5 02): köpblå som går mjukt mot rött från 80 % av huvudsäkringen, helt röd vid 100 %. */ const gcoPhCol = (share) => { const t = Math.min(1, Math.max(0, (share - 0.8) / 0.2)), s = t*t*(3 - 2*t); return s <= 0 ? '#3b82f6' : s >= 1 ? '#ef4444' : `color-mix(in oklab, #3b82f6, #ef4444 ${Math.round(s*100)}%)`; }; /* Elnät-panelen (André 26/9, v5 02 "Stråle och bredd", rask): öppnas med tryck på Elnät, ligger i Energiflödets ruta ovanför noden (under den om Elnät står i övre halvan) och pekar på ringen. Bara mätvärden: ström per fas, andel av main_fuse_a och marginal. Ingen köp/sälj per fas: phase_current_a är belopp utan riktning. Köpt/sålt idag = summan av dagens UPPMÄTTA passerade kvartar (gridSource utfall*), kW × 0,25 h × täckt andel. */ /* v671 (André 26/9, "Man kan inte klicka Elnät längre"): panelen öppnas även utan fasström (phases null) och visar då bara köpt/sålt idag. Fasraderna kräver mätvärden. */ function GcoPhPanel({ phases, g, vbH, day, onClose }){ const num = window.gcNum, MONO = window.GC_MONO, f = phases ? phases.fuse : null; const below = g.y < 300; const gx = (g.x - 52) / 536; /* v605 (André 26/9): panelen sitter direkt mot cirkeln, både under och över noden, som Elnät. */ const edge = below ? (g.y + g.r + 16 - 38) / vbH : (g.y - g.r - 16 - 38) / vbH; const ax = Math.max(8, Math.min(92, (gx - 0.03) / 0.94 * 100)); const meas = (day || []).filter((x) => x && x.isPast && /^utfall/.test(x.gridSource || '')); const kwh = (k) => meas.reduce((t, x) => t + (x[k] || 0) * 0.25 * (x.actPartial || 1), 0); const today = meas.length ? { buy: kwh('gridIn'), sell: kwh('gridOut') } : null; const pos = below ? { top: (edge*100) + '%' } : { bottom: ((1 - edge)*100) + '%' }; const phRef = React.useRef(null); React.useLayoutEffect(() => { gcoBeam(phRef.current, below); }, []); return (
{phases ? 'Elnät per fas' : 'Elnät'} {today && köpt {num(today.buy, 1)} · sålt {num(today.sell, 1)} kWh idag}
{phases && phases.v.map((amp, k) => { const share = f && amp != null ? amp / f : null; const col = share == null ? '#475569' : gcoPhCol(share); return (
{/* v611: ingen färgplupp före L1–L3 (André 26/9). */} L{k + 1} {amp == null ? '–' : num(amp, 1) + ' A'} {share == null ? '' : Math.round(share*100) + ' %'} {share != null && {num(Math.max(0, f - amp), 1)} A kvar }
); })}
{!phases ? 'Ström per fas saknas i mätningen' : f ? `Huvudsäkring ${f} A · röd från 80 %` : 'Huvudsäkringen saknas i inställningarna'}
); } /* Batteri-panelen (André 26/9, modell 01 ur Batteri tre batterier.html): tryck på en batterinod, samma stråle som Elnät. Bara mätvärden och inställningar; kWh märks beräknat (nivå × kapacitet). AI-styrda batterier (extern AI) listas med AI-etikett men ingår inte i bankens nivå, kWh, kapacitet, effekt eller idag. Säkringsvakten (F4) och idag-raden för hem med AI-batteri väntar på backend (BACKEND_BEHOV 26/9). */ /* v615 (André 26/9, iPhone 18 Pro, iOS 27.2 beta: panelen "bara dök upp"). Strålen körs med Web Animations och pixelvärden i stället för CSS-keyframes med var(--ax) i clip-path, som Safari inte animerade. Med Reducera rörelse på tonas panelen in i stället. */ const gcoBeam = (el, below) => { if (!el || !el.animate) return; const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce){ el.animate([{ opacity:0 }, { opacity:1 }], { duration:160, easing:'ease-out' }); return; } const w = el.offsetWidth, x = w*((parseFloat(getComputedStyle(el).getPropertyValue('--ax')) || 50)/100), m = w*0.01; const L = Math.max(0, x - m).toFixed(1) + 'px', R = Math.max(0, w - x - m).toFixed(1) + 'px'; const k0 = below ? `inset(-12px ${R} 100% ${L} round 14px)` : `inset(100% ${R} -12px ${L} round 14px)`; const k1 = below ? `inset(-12px ${R} 0px ${L} round 14px)` : `inset(0px ${R} -12px ${L} round 14px)`, k2 = 'inset(-12px round 14px)'; el.animate([{ clipPath:k0, webkitClipPath:k0 }, { clipPath:k1, webkitClipPath:k1, offset:0.42 }, { clipPath:k2, webkitClipPath:k2 }], { duration:465, easing:'cubic-bezier(.65,0,.35,1)' }); }; const gcoLvCol = (lv) => { const v = Math.max(0, Math.min(1, lv)); /* v607 (André 26/9): röd 0 %, orange 25 %, grön från 50 %. */ const h = v <= 0.25 ? 30*(v/0.25) : 30 + 110*Math.min(1, (v-0.25)/0.25); return `hsl(${h.toFixed(0)} 85% 55%)`; }; function GcoBatPanel({ g, vbH, s, units, day, ahead, limitW, onClose }){ const num = window.gcNum, MONO = window.GC_MONO, lbl = (q) => window.gcLabel(q % window.GC_Q), idle = window.GC_IDLE_KW || 0.15; const kwS = (v) => (v > 0.05 ? '+' : v < -0.05 ? '−' : '') + num(Math.abs(v), 1); const bats = (units && units.bats) || [], n = bats.length || 1, hasAi = bats.some((b) => b && b.aiControlled); const live = s.batSource === 'live' && !s.batUnmeasured; const perUnit = n > 1 && s.bat.length === n && !(s.bat[0] && s.bat[0].aggregate); const socs = s.socPerUnit ? s.socPct : null; const pool = bats.map((b, k) => ({ ...b, k })).filter((b) => !b.aiControlled); let soc = null, cap = null, kwh = null; if (n === 1){ soc = s.socPct && s.socPct[0] != null ? s.socPct[0] : null; cap = bats[0] ? bats[0].cap : null; } else if (socs && pool.length && pool.every((b) => b.cap && socs[b.k] != null)){ cap = pool.reduce((t, b) => t + b.cap, 0); kwh = pool.reduce((t, b) => t + socs[b.k]/100*b.cap, 0); soc = kwh/cap*100; } else if (!hasAi){ soc = s.socAggPct != null ? s.socAggPct : null; cap = units.bankCap || null; } if (kwh == null && soc != null && cap) kwh = soc/100*cap; const mins = pool.map((b) => b.socMin).filter((x) => x != null); const socMin = mins.length && mins.every((x) => x === mins[0]) ? mins[0] : null; const pP = !live ? null : perUnit ? pool.reduce((t, b) => t + ((s.bat[b.k] && s.bat[b.k].p) || 0), 0) : ((!hasAi || n === 1) && s.bat[0] ? s.bat[0].p : null); const st = !live ? 'effekt mäts inte' : pP == null ? '' : window.gcBatStatus({ p: pP, src: s.bat[0] && s.bat[0].src, srcLive: s.batSrcLive ? (pP > 0 ? s.batSrcLive.chg : s.batSrcLive.dis) : undefined }); const stCol = !live || pP == null ? '#64748b' : pP > idle ? '#4ade80' : pP < -idle ? '#7dd3fc' : '#94a3b8'; const cmds = s.batCmd || []; const cmd1 = n === 1 && cmds[0] && cmds[0].p != null ? cmds[0].p : null; const dev = cmd1 != null && pP != null && Math.abs(pP - cmd1) > 0.3; /* F4 (backend 26/9): charge_limit_w = laddeffekten i W som säkringsvakten tillåter just nu för de HEMS-styrda batterierna tillsammans. null = ingen begränsning (skrivs om på varje ping), 0 = ingen laddning alls. ?laddgrans=W i adressen visar provdata tills vakten slår till hos något hem. */ const lim = (() => { let v = limitW, demo = false; try { const q = new URLSearchParams(location.search).get('laddgrans'); if (q != null && q !== '') { v = Number(q); demo = true; } } catch(_){} return v != null && isFinite(Number(v)) && Number(v) >= 0 ? { w: Number(v), demo } : null; })(); /* Idag = summan av dagens uppmätta passerade kvartar. Historiken har bara bankens totala effekt, så med AI-batteri går det inte att räkna utan det — raden visas då inte. */ /* v612: med AI-batteri räknas banken ur bat_list per kvart (backend 26/9), bara HEMS-styrda. En kvart där listan saknas eller ett behövt värde är null räknas inte (aldrig som 0), och raden märks då "delvis". */ const today = (() => { let a = 0, b = 0, q = 0, miss = 0; (day || []).forEach((x) => { if (!x || !x.isPast || !/^utfall/.test(x.batSource || '') || x.batUnmeasured) return; let p; if (hasAi){ const L = x.batUnitsAct, v = Array.isArray(L) && L.length === n ? pool.map((bb) => L[bb.k]) : null; if (!v || v.some((y) => y == null || !isFinite(y))) { miss++; return; } p = v.reduce((t, y) => t + y, 0); } else p = (x.bat || []).reduce((t, y) => t + ((y && y.p) || 0), 0); const f = 0.25*(x.actPartial || 1); if (p > 0) a += p*f; else b -= p*f; q++; }); return q ? { a, b, partial: miss > 0 } : null; })(); const pmax = pool.length && pool.every((b) => b.pMax) ? pool.reduce((t, b) => t + b.pMax, 0) : null; const plan = (ahead || []).filter((r) => r.track === 'bat' || r.kind === 'batCov').slice(0, 2).map((r) => `${r.label.toLowerCase()} ${lbl(r.a)}–${lbl(r.b)}`); const below = g.y < 300, gx = (g.x - 52) / 536; /* v605 (André 26/9): panelen sitter direkt mot cirkeln, både under och över noden, som Elnät. */ const edge = below ? (g.y + g.r + 16 - 38) / vbH : (g.y - g.r - 16 - 38) / vbH; const ax = Math.max(8, Math.min(92, (gx - 0.03) / 0.94 * 100)); /* v604 (André 26/9, foto: panelen gick nedanför Energiflödet och klipptes). Ryms panelen inte under noden flyttas den uppåt så att underkanten slutar i flödesrutan; pilen döljs då, eftersom den inte längre pekar på noden. */ const panelRef = React.useRef(null); const [shift, setShift] = React.useState(0); React.useLayoutEffect(() => { const el = panelRef.current, par = el && el.offsetParent; if (!el || !par || !below) return; const H = par.clientHeight, baseTop = edge*H, over = baseTop + el.offsetHeight - (H - 4); const v = over > 0 ? Math.max(0, Math.min(over, baseTop - 4)) : 0; setShift((p) => Math.abs(p - v) < 1 ? p : v); }); React.useLayoutEffect(() => { gcoBeam(panelRef.current, below); }, []); const pos = below ? { top: `calc(${edge*100}% - ${shift}px)` } : { bottom: ((1 - edge)*100) + '%' }; const R = 'gco-ph-i flex items-baseline justify-between gap-2 border-t border-slate-800 py-1'; return (
{shift < 8 && }
{n > 1 ? `Batterier · ${n} st` : (bats[0] && bats[0].name) || 'Batteri'} {st && {st}}
{soc != null &&
{socMin != null && } {Math.round(soc)} %{kwh != null && cap ? <> · ca {num(kwh, 1)} / {num(cap, cap % 1 ? 1 : 0)} kWh beräknat : null}{socMin != null && lägsta {socMin} %}
} {n > 1 ? bats.map((b, k) => { const sk = socs && socs[k] != null ? socs[k] : null, p = perUnit && live && s.bat[k] ? s.bat[k].p : null, c = perUnit && cmds[k] && cmds[k].p != null && !b.aiControlled ? cmds[k].p : null; return (
{b.name}{b.aiControlled && AI} {sk == null ? '–' : Math.round(sk) + ' %'} 0.05 ? '#4ade80' : '#7dd3fc' }}>{p == null ? '–' : kwS(p) + ' kW'} {b.aiControlled ? 'ingår ej' : c != null ? 'styr ' + kwS(c) : ''}
); }) :
Effekt{pP == null ? mäts inte : {kwS(pP)} kW}
{/* v609 (André 26/9: "Du tog ju bort all information"): kommandot tillbaka som neutral rad; bara skillnadsraden (v603) är borta. */} {/* v613 (André 26/9): raden HEMS styr är borttagen helt — kommandot blev missvisande (t.ex. 0,0 kW medan batteriet laddar 4,1). Kommandot finns i HEMS beslut just nu. */}
} {lim &&
Säkringsvakten{lim.w === 0 ? ingen laddning : <>laddning högst {num(lim.w/1000, 1)} kW}{lim.demo ? · provdata : null}
} {today &&
Idag{hasAi ? ' utan AI' : ''}{today.partial ? · delvis : null}laddat {num(today.a, 1)} · urladdat {num(today.b, 1)} kWh
} {cap != null &&
Kapacitet{hasAi ? ' utan AI' : ''}{num(cap, cap % 1 ? 1 : 0)} kWh{pmax ? <> · max {num(pmax, 1)} kW : null}
} {plan.length > 0 &&
Plan{plan.join(' · ')}
}
); } /* Backendens text om uppdateringstakten (31/8), ordagrant. Låg tidigare alltid synlig i NU-kortet men tog tre rader under effektraderna; den behövs bara när någon undrar varför uppmätt avviker från kommandot, så den ligger bakom en utfällning (André 21/9). Stängd som standard, texten oförändrad. */ const GCO_FOLD_PREF = typeof window !== 'undefined' ? window.useFoldPref : null; function GcRateNote(){ const fold = GCO_FOLD_PREF ? GCO_FOLD_PREF('gco.uppdateringstakt', false) : null; const local = React.useState(false); const open = fold ? fold[0] : local[0]; const setOpen = fold ? fold[1] : local[1]; return (
{open && (

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.

)}
); } function GcOversikt({ day, tomorrow, units, nowIdx, live, tomorrowPrelim, goSchema, afterFlow }){ /* 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]?.cmd != null) || (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), /* Försäljning ur backendens läge (Lennie 14/9: klockan visade sälj men Dagens plan sa bara "Solen täcker huset"). Samma källa som ringens färg. */ sellSol: R((x) => x.mode === 'sellsol', false), sellBat: R((x) => x.mode === 'batexport' || x.bat.some((b) => b.p != null && b.p < -0.05 && b.src === 'export'), false), ...(() => { const cls = arr.map((x) => { if (x.sol == null || x.cons == null) return null; if (x.sol >= x.cons - 0.05) return 'sol'; /* Sälj täcker huset först, resten går till nätet (André 22/9). Läget batexport räknas därför som batteritäckning även när planens kvart saknar urladdning — annars stod "Nätet täcker huset" bredvid "Batteriet säljer till nätet" samma kvart. */ if (discharging(x) || x.mode === 'batexport') 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 (kind === 'sellmode'){ if (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: kind === 'sellmode' ? 'sell' : kind } : null; }; const add = (rs, track, label, col, note, priceKind, kind) => (rs || []).forEach(([a,b]) => rows.push({ a, b, track, label, col, note, kind: kind || null, price: priceKind ? priceOf(a, b, priceKind) : null })); add(plan.solCh, 'bat', (s.bat.length > 1 ? 'Batterierna laddas' : 'Batteriet laddas') + ' av sol', '#facc15', null, null, 'solCh'); add(plan.gridCh, 'bat', (s.bat.length > 1 ? 'Batterierna laddas' : 'Batteriet laddas') + ' från nätet', '#60a5fa', null, 'buy', 'gridCh'); add(plan.sellSol, 'bat', 'Solöverskott säljs', '#4ade80', null, 'sellmode', 'sell'); add(plan.sellBat, 'bat', (s.bat.length > 1 ? 'Batterierna säljer' : 'Batteriet säljer') + ' till nätet', '#2dd4bf', null, 'sellmode', 'sell'); add(plan.solCov, 'hus', 'Solen täcker huset', '#facc15', null, 'sell', 'solCov'); add(plan.batCov, 'hus', (s.bat.length > 1 ? 'Batterierna täcker' : 'Batteriet täcker') + ' huset', '#4ade80', null, 'avoid', 'batCov'); add(plan.neither, 'hus', 'Nätet täcker huset', '#60a5fa', null, 'buy', 'gridCov'); /* Krav 13: raden är kommandot start–end (cmd), inte energikvartarna. */ 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); }); /* Samma spann som husraden ⇒ ihopslagen (André 24/9: "Solen täcker huset" och "Batteriet laddas av sol" 14–16 lästes som dubblett). Husraden bär den underordnade som kort bisats och en pil; utfälld visas raden med sin egen tid, längd och pris. Bara EXAKT samma a–b slås ihop — ett kortare spann inuti är fortfarande en egen indragen rad. */ const merged = []; rows.forEach((r) => { if (!r.sub){ merged.push(r); return; } const host = merged.find((h) => h.track === 'hus' && h.a === r.a && h.b === r.b); if (host){ (host.merged = host.merged || []).push(r); } else merged.push(r); }); return merged; }; /* 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(); const flat = (rs) => rs.flatMap((r) => [r, ...(r.merged || [])]); flat(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: flat(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); /* Utfällda ihopslagna planrader (index i ahead). */ const [openRows, setOpenRows] = React.useState({}); /* Hela dygnet-chipsen: sparat läge per användare, stängt som standard. */ const dayTotPref = GCO_FOLD_PREF ? GCO_FOLD_PREF('gco.heladygnet', false) : null; const dayTotLocal = React.useState(false); const dayTot = dayTotPref ? dayTotPref[0] : dayTotLocal[0]; const setDayTot = dayTotPref ? dayTotPref[1] : dayTotLocal[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 svgEl = React.useRef(null); /* Flyttbara noder (André 25/9): pluppen nere till vänster låser upp, håll och dra en nod till en annan så byter de plats. Ordningen sparas som vypreferens (ui-prefs flowOrder) och gäller bara när den innehåller exakt hushållets noder. */ const UP = window.useUiPref; const flowPair = UP ? UP('flowOrder', null, (v) => v === null || (Array.isArray(v) && v.every((x) => typeof x === 'string'))) : React.useState(null); const flowOrder = flowPair[0], setFlowOrder = flowPair[1]; const [arrange, setArrange] = React.useState(false); const [phOpen, setPhOpen] = React.useState(false); const [batOpen, setBatOpen] = React.useState(null); /* v600 (André 26/9): tryck utanför en utfälld ruta i Energiflödet stänger den, liksom Esc. Noderna som öppnar rutor (data-gco-open) sköter sin egen växling och räknas inte som utanför. */ React.useEffect(() => { if (!phOpen && !batOpen) return; const close = () => { setPhOpen(false); setBatOpen(null); }; const onDown = (e) => { const t = e.target; if (t && t.closest && (t.closest('.gco-ph') || t.closest('[data-gco-open]'))) return; close(); }; const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('pointerdown', onDown, true); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('pointerdown', onDown, true); document.removeEventListener('keydown', onKey); }; }, [phOpen, batOpen]); const [drag, setDrag] = React.useState(null); const svgRef = React.useCallback((node) => { svgEl.current = 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). */ /* Teckengrader +10 % i takt med noderna (André 24/9), golv med. */ const FS = { val: fs(26.4,16.5), unit: fs(12.1,8.8), name: fs(15.4,12.1), stat: fs(11,9.4) }; const Y = { val: FS.val*0.02, unit: FS.val*0.58 + FS.unit*0.40 + 3, 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. */ /* Solens läge (v296–298) gäller alla noder (Lennie 14/9): +3 px glyf, 2 px högre. */ const ICON = { dy: (r) => -r*0.52 - 2, s: (r) => (r/54)*0.72 + 3/24 }; /* r/54 ⇒ glyfen följer radien, +10 % automatiskt */ /* 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. */ /* EN funktion för både rendering och subOut — två kopior av provet gled isär (foto 20/9: "−1,1 kW" låg över %-tecknet). Raden får aldrig börja ovanför %-ets underkant (n.y+8+FS.unit, hanging); den startar på Lennies mittläge eller det golvet, vilket som är lägst ner, och kordan provas där. Uppåt finns inget att hämta längre — golvet stoppar — så grader provas nedåt till 0,72·FS.unit; ryms den inte då faller raden ut under ringen. */ const subFit = (n) => { if (!n.sub) return null; const chars = String(n.sub).length; const yStart = ((8 + FS.unit) + n.r) / 2 - 8; 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; const y2 = Math.max(yStart, 8 + FS.unit + f2*0.55 + 1); 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 { fs: f2, y: y2 }; } return null; }; const subOut = (n) => !!n.sub && !subFit(n); 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. */ /* Ström per fas (backend 25/9, TILL_DESIGN fas, F2–F3). Ritas som bågar runt Elnät (v584). live.phase_current_a = [L1,L2,L3] i A, belopp utan riktning, ögonblicksbild. null = okänt, aldrig 0 A: då, eller när connected=false, visas inget alls. Marginal = main_fuse_a − största fasen. Inga provsiffror (André 25/9): saknas phase_current_a döljs raden. */ const phases = (() => { const arr = live && live.connected !== false ? live.phase_current_a : null; if (!Array.isArray(arr) || arr.length !== 3) return null; const v = arr.map((x) => x == null || !isFinite(Number(x)) ? null : Number(x)); if (v.every((x) => x == null)) return null; const fuse = (units && units.mainFuseA) || null; const known = v.filter((x) => x != null); const top = Math.max(...known), lim = v.indexOf(top); return { v, fuse, lim }; })(); const C = window.GC_C; /* v595 (André 26/9): statusraden under noderna tas bort. Enda texten är laddboxens planerade START eller beräknade KLAR ur planen (planSeq = i dag + i morgon), bara inom 24 h så klockslaget är entydigt. Laddar nu ⇒ "klar ca" = slutet på sista planerade kvarten inom 24 h, och bara om planen fortsätter efter NU. Laddar inte ⇒ "start" = nästa planerade kvart. Delad plan (flera boxar utan ev_per_car) ⇒ ingen text. */ const evNote = (k, chg, shared) => { const rs = planSeq && planSeq.ev && planSeq.ev[k]; if (!rs || !rs.length || shared) return null; const lim = nowIdx + 96, nx = nowIdx + 1, lbl = (q) => window.gcLabel(q % window.GC_Q); if (chg){ if (!rs.some(([a, b]) => a <= nx && nx < b)) return null; const e = Math.max(...rs.filter(([a]) => a < lim).map(([, b]) => b)); return e <= lim ? 'klar ca ' + lbl(e) : null; } const st = rs.filter(([, b]) => b > nx).map(([a]) => Math.max(a, nx)).filter((q) => q < lim); return st.length ? 'start ' + lbl(Math.min(...st)) : null; }; const L = window.gcLayout(s.bat.length, s.ev.length, flowOrder); const nodes = [ { id:'sol', ...L.sol, ring:'#eab308', icon:'sol', /* Ljusstyrka = produktion / anläggningens max. Ingen installerad kWp i settings ännu, så max = dygnets högsta solprognos (pv_kwp när backend ger det). Saknas allt: 1 vid produktion. */ glow: (() => { const cur = s.sol || 0; if (cur <= 0.05) return 0; const mx = Math.max(cur, ...(Array.isArray(day) ? day.map(x => x?.solPlan || 0) : [0])); return mx > 0 ? Math.min(1, cur/mx) : 1; })(), 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', ai: !!units.bats[k]?.aiControlled, /* 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. */ /* v599 (André 26/9, modell 01): samma nodmall som övriga — effekten är huvudtalet, nivån bärs av ringen och procenten längst ned i cirkeln. Minus = lämnar batteriet (som laddboxen). Mäts inte effekten: "–". */ val: (s.batSource !== 'live' || s.batUnmeasured || bp.p == null) ? '–' : ((bp.p < -GCO_IDLE_KW ? '−' : '') + svNum(Math.abs(bp.p))), unit:'kW', soc: s.socPct?.[k] != null ? s.socPct[k] : null, fill: s.socPct?.[k] != null ? s.socPct[k]/100 : null, sub: null, /* v852 (André 8/10, modell G ur mockups/Batteri AI-namn 7 varianter.html): AI-batteriet heter som de andra under noden; AI bärs av etiketten på ringen. */ name: units.bats[k]?.aiControlled ? String(window.gcBatName(units, bp, k)).replace(/ · extern AI$/, '') : 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', note: evNote(k, chg, e.shared), 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', /* v588 (André 26/9): antingen läget eller största fasen, aldrig båda. Fasen visas när den är över 10 % av huvudsäkringen; annars läget. Okänd säkring ⇒ 10 % kan inte avgöras ⇒ läget. */ status: phases && phases.fuse && phases.v[phases.lim] != null && phases.v[phases.lim] > 0.1*phases.fuse ? `max L${phases.lim + 1} ${window.gcNum(phases.v[phases.lim], 1)} A` : (s.gridOut > GCO_IDLE_KW ? 'säljer' : s.gridIn > GCO_IDLE_KW ? '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' }, ]; /* Dra och släpp (André 25/9). drag = { id, x, y } i viewBox-koordinater. Målet = närmaste andra nod inom 1,3 × radien. */ const toSvg = (e) => { const el = svgEl.current; const m = el && el.getScreenCTM && el.getScreenCTM(); if (!m) return null; const p = el.createSVGPoint(); p.x = e.clientX; p.y = e.clientY; const q = p.matrixTransform(m.inverse()); return { x:q.x, y:q.y }; }; const dropOn = drag ? (nodes.filter((n) => n.id !== drag.id).map((n) => ({ n, d: Math.hypot(n.x - drag.x, n.y - drag.y) })).sort((a, b) => a.d - b.d).find((o) => o.d < o.n.r*1.3) || {}).n || null : null; const drawNodes = drag ? [...nodes.filter((n) => n.id !== drag.id), { ...nodes.find((n) => n.id === drag.id), x: drag.x, y: drag.y }] : nodes; /* v595/v596: höjden följer lägsta nodens sista rad. Laddboxar kan få START/KLAR-noten, så deras rad reserveras ALLTID (André 26/9: bilden fick inte flytta när texten kommer). Övriga noder slutar på namnet. */ const gcoVbH = Math.max(564, Math.ceil(Math.max(...nodes.map((n) => n.y + n.r + (n.id.slice(0, 2) === 'ev' ? Y.stat + FS.stat*0.7 : Y.name + FS.name*0.6) + (subOut(n) ? FS.unit*1.5 : 0) + (n.icon === 'bat' && n.soc != null ? FS.name*1.05 : 0) + 4)) - 38)); const endDrag = () => { if (drag && dropOn){ const o = [...L.order]; const i = o.indexOf(drag.id), j = o.indexOf(dropOn.id); if (i >= 0 && j >= 0){ [o[i], o[j]] = [o[j], o[i]]; setFlowOrder(o); } } setDrag(null); }; const startDrag = (e, id) => { const p = toSvg(e); if (!p) return; e.preventDefault(); try { svgEl.current.setPointerCapture(e.pointerId); } catch(_){} setDrag({ id, ...p }); }; /* "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; /* MÄTVÄRDENA i NU-kvarten som text — samma rad i Beslutskortet ("Sker nu") och i Dagens plans NU-rad. Aldrig härlett ur kommandot; delar utan mätvärde utelämnas. */ const skerNu = (() => { const parts = []; const many = s.bat.length > 1; const batNet = s.bat.length && 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`); return { parts, batNet }; })(); /* Avviker mätningen från planradens PÅSTÅENDE? Bara direkta motsägelser av radens egna ord, med mätvärden som finns (André 24/9: "det som sker nu är vad som sker, inte vad det är planerat för"). Ingen bedömning av effektnivåer — bara riktning: täcker batteriet huset måste det ladda ur; laddas det måste det ladda; säljs det måste export finnas; täcker nätet huset måste import finnas; täcker solen huset får inget köpas. */ const planDeviates = (kind) => { const bn = skerNu.batNet, gi = s.gridIn, go = s.gridOut; switch (kind){ case 'batCov': return bn != null && bn >= -0.05; case 'solCh': case 'gridCh': return bn != null && bn <= 0.05; case 'sell': return go != null && gi != null && go <= 0.05; case 'gridCov': return gi != null && go != null && gi <= 0.05; case 'solCov': return (gi != null && gi > 0.05) || (bn != null && bn < -0.05); default: return false; } }; /* v593 (André 26/9): "Text som ser ut att förklara vad som händer skall alltid förklara verkliga värden. Annars skall det framgå att det är plan." Meningen under beslutet byggs ur MÄTVÄRDENA (samma källor och tomgångsband som tabellen). Kommandots beskrivning eller backendens reason står under, märkt "Plan:". Saknas något mätvärde skrivs ingen verklighetsmening. Ersätter sol-täcker-fallbacken (27/8). */ const realTxt = (() => { if (!s.isNow) return null; const idle = GCO_IDLE_KW; const batOk = s.batSource === 'live' && !s.batUnmeasured && s.bat.length > 0 && s.bat.every((b) => b.p != null); if (!batOk || s.solLive == null || s.gridSource !== 'live') return null; const bn = s.bat.reduce((t, b) => t + b.p, 0), sol = s.solLive, gi = s.gridIn || 0, go = s.gridOut || 0; const B = units.bats.length > 1 ? 'Batterierna' : 'Batteriet'; const bat = bn > idle ? `${B} laddar ${svNum(bn)} kW` : bn < -idle ? `${B} laddar ur ${svNum(-bn)} kW` : `${B} vilar`; const from = [sol > 0.05 && 'solen', bn < -idle && B.toLowerCase(), gi > idle && 'nätet'].filter(Boolean); const list = from.length > 1 ? from.slice(0, -1).join(', ') + ' och ' + from[from.length - 1] : from[0]; return `${bat}.${list ? ` Huset får el från ${list}.` : ''}${go > idle ? ` ${svNum(go)} kW säljs till nätet.` : ''}`; })(); const planTxt = window.gcReason(s.reason, mode.desc); /* v699 (André 27/9): ihopfällt visar kortet det viktigaste för beslutet just nu, ur mätvärdena (samma källor och tomgångsband som verklighetsraden). Ladda sol, Ladda från nät, Sälj batteri, Täck huset och Vänta: vad batteriet gör. Sälj sol: vad som går ut på nätet. Andra delen: solen vid Ladda sol och Sälj sol, annars nätet. Saknas ett mätvärde utelämnas den delen. */ const sumNode = (() => { if (!s.isNow) return null; const idle = GCO_IDLE_KW, kw = (v) => svNum(v) + ' kW'; const batOk = s.batSource === 'live' && !s.batUnmeasured && s.bat.length > 0 && s.bat.every((b) => b.p != null); const bn = batOk ? s.bat.reduce((t, b) => t + b.p, 0) : null; const gridOk = s.gridSource === 'live', gi = s.gridIn || 0, go = s.gridOut || 0; const B = units.bats.length > 1 ? 'Batterierna' : 'Batteriet'; const part = (t, v, tail) => ({ t, v, tail: tail || '' }); const bat = bn == null ? null : bn > idle ? part(B + ' laddar ', kw(bn)) : bn < -idle ? part(B + ' laddar ur ', kw(-bn)) : part(B + ' vilar'); const grid = (cap) => !gridOk ? null : go > idle ? part((cap ? 'S' : 's') + 'äljer ', kw(go), ' till nätet') : gi > idle ? part((cap ? 'K' : 'k') + 'öper ', kw(gi), ' från nätet') : part((cap ? 'N' : 'n') + 'ätet vilar'); const sol = s.solLive == null ? null : part('sol ', kw(Math.max(0, s.solLive))); const m = s.mode; const prim = m === 'sellsol' ? grid(true) : bat; const sec = m === 'solcharge' || m === 'sellsol' ? sol : grid(false); const first = prim || (sec ? { ...sec, t: sec.t.charAt(0).toUpperCase() + sec.t.slice(1) } : null), second = prim ? sec : null; if (!first) return null; const show = (p) => <>{p.t}{p.v && {p.v}}{p.tail}; return {show(first)}{second && · {show(second)}}; })(); return (
{/* ENERGIFLÖDET ÄR ALLTID ÖVERST (André 13/9). Beslutskortet ligger först i DOM men flyttas visuellt under flödet via order: flödet är order 0, beslutet 1, Dagens plan och resten 2. */} {/* Beslutet självt (färgprick + läge) står kvar i huvudraden: en NU-vy där kommandot ligger bakom ett klick vore inte en NU-vy. Resten — motivering, Sker nu, Visa detaljer, rekommenderad effekt — fälls ut (André 21/9). */} {(window.gcLimit && window.gcLimit(s.mode, s.limit))?.label || mode.label} }> {realTxt &&

{realTxt}

} {(() => { const lim = window.gcLimit && window.gcLimit(s.mode, s.limit); return lim?.sub ?

{lim.sub}

:

Plan: {planTxt}

; })()} {/* 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. */} {/* PLAN MOT UPPMÄTT (André 24/9, mockup 2): en tabell med två talkolumner ersätter "Sker nu"-meningen och listan Rekommenderad effekt. Plan är kommandot, Uppmätt är mätaren; sol och nät har ingen plan här och står med "—" i den kolumnen. Scope-reglerna för batterier (aggregat mot per enhet) är oförändrade från listan. Avvikelse > 0,3 kW färgar det uppmätta talet amber — ingen tolkning av orsaken. */} {(() => { 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 == null ? '—' : `${v >= 0 ? '' : '−'}${svNum(Math.abs(v), 2)}`; const sum = (arr) => arr.every((x) => x.p != null) ? arr.reduce((t, x) => t + x.p, 0) : null; /* v590 (André 26/9: raden sa "vilar" medan flödet visade urladdning). Status och Uppmätt läses bara ur mätaren, samma funktion som Energiflödet (gcBatStatus på uppmätt effekt). Tidigare lästes statusen ur kommandot c. Ingen live-batterieffekt (batSource ≠ live eller battery_power_measured=false) ⇒ — och "okänt", aldrig planen i mätkolumnen. */ const batLive = s.batSource === 'live' && !s.batUnmeasured; const measOf = (k) => !batLive ? null : cmdAgg ? sum(s.bat) : measAgg ? null : (s.bat[k]?.p ?? (s.bat.length === 1 ? s.bat[0]?.p : null)); const measObj = (k, m) => cmdAgg ? { p: m, src: cmds[0]?.src, srcLive: s.batSrcLive ? (m > 0 ? s.batSrcLive.chg : s.batSrcLive.dis) : undefined } : { ...(s.bat[k] || s.bat[0]), p: m }; const cmdSum = measAgg ? sum(cmds.filter((c) => !c.ai)) : null; const rows = []; cmds.forEach((c, k) => { const m = measOf(k); const real = measAgg ? 'mäts som bank' : m == null ? 'okänt' : window.gcBatStatus(measObj(k, m)); rows.push({ key:'b'+k, col:'#4ade80', name: window.gcBatName(units, (!cmdAgg && !measAgg && s.bat[k]) ? s.bat[k] : c, k), st: c.ai ? (m == null ? 'AI-styrt' : 'AI-styrt · ' + real) : real, plan: c.p, meas: m, dev: c.p != null && m != null && Math.abs(m - c.p) > 0.3 }); }); const bankM = measAgg && batLive ? s.bat[0].p : null; if (measAgg) rows.push({ key:'bank', col:'#4ade80', name:'Hela banken', st: bankM == null ? 'okänt' : window.gcBatStatus(s.bat[0]), plan: cmdSum, meas: bankM, dev: cmdSum != null && bankM != null && Math.abs(bankM - cmdSum) > 0.3 }); /* Laddbox: bara ev_power_list är mätning (e.measured, v590). Utan den bär e.p planen. */ s.ev.forEach((e, k) => { const m = e.measured === true ? e.p : null; rows.push({ key:'e'+k, col:'#a78bfa', name: e.name || units.evs[k]?.name, st: m == null ? 'okänt' : m < -0.05 ? 'matar huset' : m > 0.05 ? (e.src ? `laddar · ${e.src}` : 'laddar') : 'ingen laddning', plan: null, meas: m, dim: !(m != null && Math.abs(m) > 0.05) }); }); /* Last utan effektmätning: x.p är planens effekt, så "körs" och 0,00 i Uppmätt var planen (v590). Nu "ej mätt" och —; planen står kvar i Plan. */ (s.loads || []).forEach((x, k) => { const meas = x.measured === true, on = x.p != null && x.p > 0.05, rep = units.loads[k]?.reportsPower; const planP = x.pPlan != null ? x.pPlan : (!meas && on ? x.p : (!on ? 0 : null)); rows.push({ key:'l'+k, col:'#F5C518', name: units.loads[k]?.name || 'Last ' + (k+1), st: !meas ? 'ej mätt' : on ? `körs${x.src ? ' ' + x.src : ''}` : 'körs inte nu', plan: planP, meas: meas ? x.p : null, measNote: !meas && on && rep ? 'visas ej ännu' : null, dim: !(meas && on) }); }); /* Solen har en plan: timprognosen (s.solPlan). Huset likaså (s.consPlan, utan skjutbara laster — de är egna rader). Uppmätt är live-värdet; utan live (s.solLive/consLive null) står "—" så prognosen inte läses som mätning. Avvikelse markeras > 0,3 kW som för batteriet. */ if (s.solPlan != null || s.solLive != null) rows.push({ key:'sol', col:'#F5C518', name:'Sol', st: s.solLive == null ? 'okänt' : s.solLive > 0.05 ? 'producerar' : 'vilar', plan:s.solPlan, meas:s.solLive, dev: s.solPlan != null && s.solLive != null && Math.abs(s.solLive - s.solPlan) > 0.3, dim:(s.solLive ?? s.solPlan ?? 0) <= 0.05 }); /* HUS = huset utan smartlaster och bilar (André 24/9: "summorna behöver gå ihop"). Planen (consPlan) är redan utan laster. Mätaren home_consumption är HELA huset inkl. laster, så de uppmätta lasterna dras bort. Kör en last utan effektmätning kan den inte dras bort — då visas inget tal (—) med status "inkl. last, ej mätt" hellre än ett fel tal. Bilar ingår inte i home_consumption (adaptern) och dras inte bort. */ const loadsOn = (s.loads || []).filter((x) => x.p != null && x.p > 0.05); const loadsUnmeasured = loadsOn.some((x) => x.measured !== true); const loadsMeasSum = loadsOn.filter((x) => x.measured === true).reduce((t, x) => t + x.p, 0); const husLive = s.consLive == null ? null : loadsUnmeasured ? null : Math.max(0, s.consLive - loadsMeasSum); /* Säkring (André 24/9): slår backend om consIncludesLoads=true bär consPlan lasterna — då används consBase (final − loads_add) eller consPlan − consLoadsAdd. Finns ingen av dem visas ingen plan (—) hellre än en med VVB i. */ const husPlan = !s.consIncludesLoads ? s.consPlan : s.consBase != null ? s.consBase : (s.consPlan != null && s.consLoadsAdd != null) ? Math.max(0, s.consPlan - s.consLoadsAdd) : null; if (husPlan != null || s.consLive != null) rows.push({ key:'hus', col:'#cbd5e1', name:'Hus', st: s.consLive == null ? 'okänt' : loadsUnmeasured ? 'förbrukar · inkl. last, ej mätt' : 'förbrukar', plan:husPlan, meas:husLive, measNote: loadsUnmeasured && s.consLive != null ? '—' : null, dev: husPlan != null && husLive != null && Math.abs(husLive - husPlan) > 0.3, dim:(husLive ?? husPlan ?? 0) <= 0.05 }); /* Nät: bara live grid_power är mätning; annars bär gridIn/gridOut planen (v590). */ const gLive = s.gridSource === 'live'; if (s.gridIn != null || s.gridOut != null) rows.push({ key:'grid', col:'#60a5fa', name:'Nät', st: !gLive ? 'okänt' : (s.gridOut||0) > GCO_IDLE_KW ? 'säljer' : (s.gridIn||0) > GCO_IDLE_KW ? 'köper' : 'vilar', plan:null, meas: !gLive ? null : (s.gridOut||0) > 0.05 ? -s.gridOut : (s.gridIn||0), dim: !gLive || !((s.gridOut||0) > GCO_IDLE_KW || (s.gridIn||0) > GCO_IDLE_KW) }); /* Fast ordning (André 24/9): Sol → Hus → Batteri → Nät, sedan bilar och laster. Källan först, sedan flödet genom huset, det flyttbara sist. */ const ORDER = { sol:0, hus:1, b:2, bank:3, grid:4, e:5, l:6 }; const rank = (r) => ORDER[r.key.replace(/\d+$/, '')] ?? 9; rows.sort((x, y) => rank(x) - rank(y)); return (
Enhet · kW Plan Uppmätt {rows.map((r) => ( {/* Namn + status får bryta rad (verifieraren 24/9: truncate klippte "ingen laddning" på 402 px). Statusen är betydelsen, den får aldrig försvinna. */} {r.name} · {r.st} {kw(r.plan)} {r.meas == null ? (r.measNote || '—') : kw(r.meas)} ))}
); })()}
Energiflöde just nu {mode.label}
{/* Faserna (v581) ligger i flödet på stämpelraden, till höger, inte absolut över SVG:n: på telefon krymper ringen medan en fast ruta inte gör det, och v580 täckte batterinoden (verifieraren 25/9). En rad, bryts under stämpeln om den inte ryms. */} {liveStamp && (
{liveStamp.txt}
)} {/* Instruktionen i huvudet, inte vid pluppen: där krockade den med nedersta nodens etiketter på telefon (verifieraren 25/9). */} {arrange && (
håll och dra en enhet till en annan {!L.isDefault && }
)} {/* 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. */} {/* Höjden följer den lägsta nodens statusrad (foto 20/9: utan laddbox hamnar Elnät rakt under navet, y 500+54, och på telefon där fs()-golvet höjer namn/status till 22/17 enheter låg "VILAR" på ~613 — utanför de fasta 602). 564 är golvet, så desktop och 5+ noder ritas som förut. */}
{ const p = toSvg(e); if (p) setDrag((d) => d && { ...d, ...p }); } : undefined} onPointerUp={drag ? endDrag : undefined} onPointerCancel={drag ? () => setDrag(null) : undefined} viewBox={`52 38 536 ${gcoVbH}`} className="block w-full select-none"> {drawNodes.map((n) => ( {n.active && ( )} ))} {!drag && 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 ( ); })} {drawNodes.map((n) => ( startDrag(e, n.id) : undefined} onClick={arrange ? undefined : n.id === 'grid' ? () => { setBatOpen(null); setPhOpen((o) => !o); } : n.icon === 'bat' ? () => { setPhOpen(false); setBatOpen((o) => o === n.id ? null : n.id); } : undefined} style={arrange ? { cursor: drag && drag.id === n.id ? 'grabbing' : 'grab' } : { cursor: n.id === 'grid' || n.icon === 'bat' ? 'pointer' : undefined, opacity: (phOpen && n.id !== 'grid') || (batOpen && n.id !== batOpen) ? 0.22 : 1, transition:'opacity .3s' }}> {arrange && } {n.active && ( )} {n.icon === 'bat' && n.soc != null && (() => { /* v599 modell 01: ringen fylls medurs till nivån i nivåfärg (samma skala som batteriikonen). AI-batteri: violett spår. */ /* v640: samma radie som Elnäts fasbågar (n.r + 6), så nodens kant syns innanför och ringen ser lika tjock ut. */ const lv = Math.max(0, Math.min(1, n.soc/100)), R = n.r + 6, a = Math.max(4, 360*lv)*Math.PI/180; const x1 = n.x + R*Math.sin(a), y1 = n.y - R*Math.cos(a), col = gcoLvCol(lv); /* v608: ringen i nivåfärg (0 % röd, 25 % orange, grön från 50 %). */ return ( {lv >= 0.999 ? : 0.5 ? 1 : 0} 1 ${x1},${y1}`} fill="none" stroke={col} strokeWidth="4.5" strokeLinecap="round">} ); })()} {n.id === 'grid' && phases && (() => { /* Faserna som ringen runt Elnät (André 25/9, mockup A): tre bågar à 110° med 10° glapp, mitt kl. 12, 4 och 8 så glappet hamnar rakt under noden där namnet står. Fylls mot main_fuse_a; amber från 90 % på högsta fasen, röd över säkringen. Tryck på Elnät visar L1–L3 med värden. */ const R = n.r + 6, sw = 4.5, mids = [-90, 30, 150]; const pt = (deg) => { const a = deg*Math.PI/180; return [n.x + Math.cos(a)*R, n.y + Math.sin(a)*R]; }; const arc = (m) => { const [x0, y0] = pt(m - 55), [x1, y1] = pt(m + 55); return `M${x0},${y0} A${R},${R} 0 0 1 ${x1},${y1}`; }; return ( {phases.fuse ? `Ström per fas mot huvudsäkringen ${phases.fuse} A` : 'Ström per fas'} {phases && phases.v.map((amp, k) => { const f = phases.fuse, share = f && amp != null ? Math.max(0, amp / f) : null; /* v594 (André 26/9, mockup v5 02): blå mot rött, mjukt från 80 % (smoothstep), helt röd vid säkringen. Riktning per fas saknas (backend skickar belopp), så alltid köpfärgen. */ const col = share == null ? '#475569' : gcoPhCol(share); const d = arc(mids[k]); /* v585: sidoetiketterna klämdes utanför viewBox (52–588) på mobil när Elnät ligger ytterst — flytta in så hela texten ryms. */ const lbl = `L${k + 1} ${amp == null ? '–' : window.gcNum(amp, 1)}`, tw = lbl.length*FS.stat*0.62; const [lx, ly] = (() => { const a = mids[k]*Math.PI/180, rr = R + FS.stat*1.1; let x = n.x + Math.cos(a)*rr; if (mids[k] === 30) x = Math.min(x, 586 - tw); else if (mids[k] === 150) x = Math.max(x, 54 + tw); return [x, n.y + Math.sin(a)*rr]; })(); return ( {f && share != null && share > 0 && } {!f && amp != null && } {/* v594: värdena visas i Elnät-panelen (GcoPhPanel), inte runt ringen. */} ); })} ); })()} {/* Solen SKINER (Lennie 14/9): gloria + fylld skiva bakom glyfen, båda skalade med produktionen (glow 0–1). Släckt ⇒ inget av detta. */} {n.id === 'sol' && n.glow > 0 && (() => { const cx = n.x, cy = n.y+ICON.dy(n.r), d = 24*ICON.s(n.r), gl = n.glow; return ( ); })()} {/* Batteriet (Lennie 14/9): talets underkant på cirkelns centrum. */} {n.val} {n.unit} {/* v641: procenttexten i noden borttagen (André 26/9: tog i kanten). Sedan v695 står procenten efter namnet under noden. */} {/* 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 eller över %-tecknet. Provet ligger i subFit (samma som subOut använder). Utanför: raden hamnar mellan ring och nodnamn, och namn/status skjuts ner. */ const f = subFit(n); if (f) return ( {n.sub} ); return ( {n.sub} ); })()} {/* v852 (André 8/10, modell G): AI-etiketten sitter på ringen snett ovanför till höger, så namnraden inte blir bredare. */} {n.ai && (() => { const R = n.r + 6, a = -Math.PI/4, bx = n.x + Math.cos(a)*R, by = n.y + Math.sin(a)*R, w = FS.unit*2.1, h = FS.unit*1.35; return ( AI ); })()} {n.name} {/* v852 (modell G): nivån på egen rad under namnet för alla batterier, i ringens nivåfärg (förut efter namnet, v695). Smalt hårt mellanslag före % (v696). */} {n.icon === 'bat' && n.soc != null && {Math.round(n.soc) + '\u202F%'}} {/* v595: ingen statusrad (André 26/9) — bara laddboxens START/KLAR ur planen. */} {n.note && {n.note.toUpperCase()}} ))} {/* Lennie 14/9: HEMS halvvägs upp mot orbets kant (−20 → −41), +2 px; lägespunkten +1 px och lägestexten med överkanten genom centrum. */} {!tight && HEMS} {mode.label} {!tight && styrcentral} {phOpen && !arrange && (() => { const g = nodes.find((x) => x.id === 'grid'); const vbH = gcoVbH; return g ? setPhOpen(false)}/> : null; })()} {batOpen && !arrange && (() => { const g = nodes.find((x) => x.id === batOpen); return g ? setBatOpen(null)}/> : null; })()}
{/* Låsplupp nere till vänster (André 25/9): låser upp flytt av noderna. */}
{/* Slot direkt under Energiflödet (order 0, efter flödet i DOM). */} {afterFlow} {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.

) : (
{/* Varje rad är ett tryckbart fönster (André 24/9): stängd visar tid, beslut, längd och pris; öppen visar samtidiga beslut i spannet med egen tid och pris, och för NU-raden mätvärdena. Pilen längst till höger säger att det finns mer. */} {timeline.ahead.map((r, i) => { const isNow = r.a <= nowIdx && r.b > nowIdx; const hasMore = !!(r.merged && r.merged.length) || (isNow && skerNu.parts.length > 0); const open = !!openRows[i]; return (
setOpenRows((o) => ({...o, [i]: !o[i]})) : undefined} onKeyDown={hasMore ? (e) => { if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); setOpenRows((o) => ({...o, [i]: !o[i]})); } } : undefined} className={'gc-plan-row flex items-baseline gap-2 py-1.5' + (i === 0 && !open ? ' pt-0' : '') + (r.sub ? ' gc-plan-sub' : '') + (hasMore ? ' cursor-pointer' : '')}> {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}} {/* NU-raden stängd: avviker mätningen från planens ord står det som sker i amber direkt under. Stämmer de skrivs inget här — mätvärdena finns i det öppna fönstret. */} {isNow && !open && planDeviates(r.kind) && skerNu.parts.length > 0 && ( Sker nu · {skerNu.parts.join(' · ')} )} {/* 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 )} {window.hemsVals(dur(r.b - r.a))} {hasMore && }
{open && (
{isNow && skerNu.parts.length > 0 && (
Sker nu · {skerNu.parts.join(' · ')} {planDeviates(r.kind) && — avviker från planen}
)} {(r.merged || []).map((m, k) => (
{qLabel(m.a % window.GC_Q)}–{qLabel(m.b % window.GC_Q)} {m.label}{m.note && {m.note}} {m.price && {m.price.kind === 'sell' ? 'säljpris' : m.price.kind === 'avoid' ? 'sparar' : 'köppris'} {m.price.v} öre}
))}
)}
); })}
{/* 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 bakom en utfällning (André 24/9), läget sparas per användare som övriga fällkort (useFoldPref). Stängd som standard. */}
{dayTot && (
{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 });