/* ============================================================================ GC — Gravio-kopian: delade grunder ---------------------------------------------------------------------------- Läge "Ny" ska vara kollegans app, inte våra vyer i vårt skal. Därför bor HANS visuella vokabulär här: hans MODES-färger (hex, inte våra CSS-variabler), hans LABEL/CARD-klasser, hans MONO-stack. Tailwind-klasserna i gc-*-filerna är hans egna strängar — de körs mot Play-CDN:en med preflight AV, så gamla designen är orörd. Skillnaden mot hans fil är datakällan: allt kommer ur buildGravioDay(), och där han hårdkodar tre batterier och två laddboxar räknar vi enheterna ur settings. Hushållet har ett batteri och en laddbox — noderna följer antalet. ========================================================================== */ const GC_Q = 96; /* Hans MODES, med hans hex. Priserna kommer från backend inklusive avgifter, så hans price/sub (spot + NET_ORE) är medvetet inte portade. */ const GC_MODES = { /* 'buy' är i adapterkedjan ALLTID batteriladdning från nät (charge/ chargegrid/ladda_nat mappar hit — inget annat gör det). "Köp nät" lät som husets vanliga inköp och lästes fel mot ett vilande batteri (André 29/8: "det står köp nät men kommandot är väl ladda batteriet?"). */ buy: { label:'Ladda från nät', color:'#2563eb', desc:'Billig el laddar batterierna — inför dyrare timmar.' }, sellsol: { label:'Sälj sol', color:'#4ade80', desc:'Vi säljer sol direkt — bättre nu än senare.' }, /* Ingen tid på dygnet i desc: läget inträffar kl 10 lika väl som på kvällen (André 8/9 och igen 21/9 — "Kvällens toppris" kom tillbaka med en backend-baseline och stod på skärmen 10:12). */ batexport: { label:'Sälj batteri', color:'#2dd4bf', desc:'Höga priser nu — batterierna säljer till nätet.' }, solcharge: { label:'Ladda sol', color:'#fde047', desc:'Solöverskottet laddar batterierna inför kvällen.' }, battery: { label:'Täck huset', color:'#fb923c', desc:'Batterierna driver huset — vi undviker dyrt köp.' }, vanta: { label:'Vänta', color:'#64748b', desc:'Ingen styrning just nu — läget avvaktar.' }, }; /* Källfärger = LÄGESFÄRGERNA (Lennie 14/9): sol = Ladda sol, nät = Ladda från nät, batteri = Täck huset. En sak, en färg genom hela systemet. */ const GC_EV_SRC_COL = { sol: GC_MODES.solcharge.color, 'nät': GC_MODES.buy.color, batteri: GC_MODES.battery.color }; /* Lastspårens källfärger = systemets (Lennie 14/9): sol gul, nät blå, batteri orange. Blandat (sol+nät i samma körning) var limegrön och lästes som en egen enhet; nu lägesringens cyan, som redan betyder "blandad affär". */ const GC_LOAD_COL = { sol: GC_MODES.solcharge.color, 'nät': GC_MODES.buy.color, batteri: GC_MODES.battery.color, blandat: GC_MODES.batexport.color }; /* Blandad källa som VERKLIG blandning (Lennie 14/9): färgen viktas mellan de två källorna efter andel, interpolerad i OKLab så mellantonerna blir rena (gul→blå i sRGB blir smutsgrå). share = andel av färg A. */ function gcHexToRgb(h){ const n = parseInt(h.slice(1), 16); return [(n>>16)&255, (n>>8)&255, n&255].map(v => v/255); } function gcRgbToOklab([r,g,b]){ const f = (c) => c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); const [R,G,B] = [f(r), f(g), f(b)]; const l = Math.cbrt(0.4122214708*R + 0.5363325363*G + 0.0514459929*B); const m = Math.cbrt(0.2119034982*R + 0.6806995451*G + 0.1073969566*B); const s = Math.cbrt(0.0883024619*R + 0.2817188376*G + 0.6299787005*B); return [0.2104542553*l + 0.7936177850*m - 0.0040720468*s, 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, 0.0259040371*l + 0.7827717662*m - 0.8086757660*s]; } function gcOklabToHex([L,a,b]){ const l = Math.pow(L + 0.3963377774*a + 0.2158037573*b, 3), m = Math.pow(L - 0.1055613458*a - 0.0638541728*b, 3), s = Math.pow(L - 0.0894841775*a - 1.2914855480*b, 3); const R = 4.0767416621*l - 3.3077115913*m + 0.2309699292*s, G = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s, B = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s; const g = (c) => { c = Math.max(0, Math.min(1, c)); c = c <= 0.0031308 ? 12.92*c : 1.055*Math.pow(c, 1/2.4) - 0.055; return Math.round(c*255).toString(16).padStart(2, '0'); }; return '#' + g(R) + g(G) + g(B); } function gcMixCol(hexA, hexB, shareA){ const t = Math.max(0, Math.min(1, shareA == null ? 0.5 : shareA)); const A = gcRgbToOklab(gcHexToRgb(hexA)), B = gcRgbToOklab(gcHexToRgb(hexB)); return gcOklabToHex([A[0]*t + B[0]*(1-t), A[1]*t + B[1]*(1-t), A[2]*t + B[2]*(1-t)]); } /* Lastspårets färg ur källa + solandel: blandat ⇒ sol/nät viktat. */ /* Blandat ⇒ den DOMINERANDE källans färg (Lennie 14/9): mest sol ⇒ gul, mest nät ⇒ blå. Ingen mellanton — en last har en färg. */ function gcLoadCol(src, solarShare){ if (src === 'blandat') return (solarShare == null || solarShare >= 0.5) ? GC_LOAD_COL.sol : GC_LOAD_COL['nät']; return GC_LOAD_COL[src] || null; } const GC_MONO = { fontFamily:'"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace' }; const GC_LABEL = 'text-[9px] tracking-[0.15em] text-slate-500 font-semibold uppercase truncate'; const GC_CARD = 'rounded-xl border border-slate-800 bg-slate-900/60 px-2.5 py-2 flex flex-col gap-0.5 min-w-0'; const gcNum = (n, d = 1) => (n == null || !isFinite(n)) ? '—' : Number(n).toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); const gcLabel = (i) => `${String(Math.floor(i/4)).padStart(2,'0')}:${String((i%4)*15).padStart(2,'0')}`; const gcRange = (i) => `${gcLabel(i)}–${gcLabel((i+1) % GC_Q)}`; const gcMode = (m) => GC_MODES[m] || GC_MODES.vanta; /* Intervall ur ett dygn. smooth=true — prognosbundna predikat (sol ≥ förbrukning): enstaka avvikande kvartar slätas ut och intervall under 30 min hoppas, precis som i hans fil. smooth=false — predikat som läser backendens egen källa (bat.src, ev.src, last på/av). Utjämning där SUDDAR UT verkliga hål; en kvart försäljning på 8,5 kW är inte brus. (Bugg funnen 2026-08-16.) */ function gcRanges(day, pred, smooth){ const n = day.length; const b = day.map(pred); if (smooth) for (let i = 1; i < n-1; i++) if (!b[i] && b[i-1] && b[i+1]) b[i] = true; const min = smooth ? 2 : 1; const out = []; let st = null; for (let i = 0; i <= n; i++){ const ok = i < n && b[i]; if (ok && st === null) st = i; if (!ok && st !== null){ if (i - st >= min) out.push([st, i]); st = null; } } return out; } /* Nodplacering — gamla flödesvyns jämna cirkel (flow.jsx → flow-v2.jsx). Hans fil skriver in koordinater för exakt tre batterier och två laddboxar; med andra antal lämnade de tomma hörn eller lade noder ovanpå varandra, och sektorbågen som täckte överskottet gav ojämna mellanrum. Här fördelas ALLA noder jämnt över 360° oavsett antal: sol i topp, sedan medurs batterier → elnät → laddboxar baklänges → hemmet. Cirkeln andas efter antalet enheter — fem noder ger en femhörning, åtta en oktagon. Radien krymper med antalet så grannar aldrig nuddar varandra. Resten av hans flödesvy är oförändrad. */ const GC_C = { x:320, y:300 }; const GC_R = 200; function gcLayout(nBats, nEvs, custom){ const def = ['sol', ...Array.from({length:nBats},(_,k)=>'b'+k), 'grid', ...Array.from({length:nEvs},(_,k)=>'ev'+k).reverse(), 'hem']; /* Egen ordning (André 25/9, flyttbara noder) gäller bara om den har exakt samma noder. */ const order = Array.isArray(custom) && custom.length === def.length && def.every((id) => custom.includes(id)) ? custom : def; const n = order.length; /* +10 % (André 24/9): 54→59, 50→55, 46→51, 42→46, 38→42. Orbit 200 oförändrad; vid 5 noder är grannavståndet ~235, så 2×59 ryms med marginal. */ const r = n <= 5 ? 59 : n === 6 ? 55 : n === 7 ? 51 : n <= 9 ? 46 : 42; const step = 360 / n; const out = { bats:[], evs:[] }; order.forEach((id, i) => { const a = (-90 + i*step) * Math.PI/180; const p = { x: GC_C.x + Math.cos(a)*GC_R, y: GC_C.y + Math.sin(a)*GC_R, r }; if (id === 'sol') out.sol = p; else if (id === 'grid') out.grid = p; else if (id === 'hem') out.hem = p; else if (id[0] === 'b') out.bats[+id.slice(1)] = p; else out.evs[+id.slice(2)] = p; }); out.order = order; out.isDefault = order === def; return out; } /* Tomgångsband för statustexten (v592). Lennies pingar (LEN) 26/9, 10–20 s-upplösning senaste ~2 h: nätet ligger mest på 20–50 W när batteriet balanserar, och enstaka pingar sticker till 52–95 W för att vara tillbaka nästa ping; batteriet 58–84 W på samma sätt. Under bandet är statusen "vilar" — talet visas ändå som uppmätt. 0,15 kW ger marginal över största observerade (95 W). */ const GC_IDLE_KW = 0.15; /* Batteriets status i hans ordval. src kommer från backendens action; vid NU går srcLive (ur mätaren, gravio-adapter v591) före. '?' = källan går inte att avgöra. */ function gcBatStatus(bp){ if (bp == null || bp.p == null) return 'okänt'; const src = bp.srcLive !== undefined ? bp.srcLive : bp.src; if (bp.p > GC_IDLE_KW) return src === 'sol' ? 'laddar · sol' : src === 'nät' ? 'laddar · nät' : src === 'blandat' ? 'laddar · sol + nät' : 'laddar'; if (bp.p < -GC_IDLE_KW) return src === 'export' ? 'säljer' : src === 'blandat' ? 'täcker hus · säljer' : src === '?' ? 'laddar ur' : 'täcker hus'; return 'vilar'; } /* Namn per batterinod. Är planen aggregerad för flera batterier finns bara summan — då är EN nod sanningen, och den ska heta det. */ function gcBatName(units, bat, k){ if (bat.aggregate) return units.bats.length > 1 ? `Batteri · summa (${units.bats.length} st)` : (units.bats[0]?.name || 'Batteri'); return units.bats[k]?.name || `Batteri ${k+1}`; } /* Backendens reason är ibland LP-loggen ("LP: chargesolar | PowerMatch: …"), inte en mening för användaren. Visa den aldrig — falla tillbaka på lägets egen beskrivning. ENDA filtret i projektet — app.jsx, mobile.jsx, forecast.jsx, flow-v2.jsx och gc-oversikt.jsx anropar alla hit. Lägg inte en egen regex någon annanstans: tre kopior drev isär och släppte olika prefix. */ const GC_DEBUG_REASON = /→|\||\b(LP|BgLP|PowerMatch|hysteres|reaktiv|HEMS|EV-pause|selfconsumption|chargesolar|sellsolar|zeroexport|discharge)\b/i; /* Säkringsbegränsning (kontrakt bekräftat av Claude Code 20/9). limit finns på aktuellt kommando i /api/live-data och per rad i /api/decision-history, null när skyddet inte klämt. Två former: by:'fuse' { lp_kw (planerat), allowed_kw (skickat), house_kw, ev_kw, solar_kw, fuse_a, fuse_kw, text } by:'fuse_overload' { grid_kw, battery_kw, streak, fuse_kw, text } text är backendens färdiga svenska mening och används som sub när den finns; annars skrivs meningen här ur talen. Homey-token förblir charge, så label ERSÄTTER lägesnamnet ("Ladda från nät" när inget laddar var felet, Lennie 20/9). Bara charge-lägen (buy/solcharge) begränsas; övriga lägen returnerar null. */ function gcLimit(mode, lim){ if (!lim || typeof lim !== 'object' || !lim.by) return null; if (mode !== 'buy' && mode !== 'solcharge') return null; const n1 = (v) => gcNum(v, 1); const txt = typeof lim.text === 'string' && lim.text.trim() ? lim.text.trim().replace(/(\d)\.(\d)/g, '$1,$2') : null; if (lim.by === 'fuse_overload'){ const g = lim.grid_kw != null ? n1(lim.grid_kw) : null, f = lim.fuse_kw != null ? n1(lim.fuse_kw) : null; return { paused:true, label:'Pausad — importen översteg säkringen', sub: txt || (g && f ? 'Importen ' + g + ' kW mot säkringens ' + f + ' kW.' : 'Importen översteg säkringen.') }; } const allowed = Number(lim.allowed_kw) || 0, lp = Number(lim.lp_kw); const house = lim.house_kw != null ? 'hus ' + n1(lim.house_kw) + ' kW' : null; const fuse = lim.fuse_a != null ? 'säkring ' + Math.round(lim.fuse_a) + ' A' : (lim.fuse_kw != null ? 'säkring ' + n1(lim.fuse_kw) + ' kW' : null); const paren = [house, fuse].filter(Boolean).join(', '); const par = paren ? ' (' + paren + ')' : ''; const lpTxt = isFinite(lp) ? n1(lp) : '—'; if (allowed <= 0.05){ return { paused:true, label:'Väntar — säkringsskydd', sub: txt || ('Laddning stoppad av säkringsskyddet: planerat ' + lpTxt + ' kW, tillåtet 0 kW' + par + '.') }; } return { paused:false, label:'Ladda ' + n1(allowed) + ' kW · begränsad av säkringsskyddet', sub: txt || ('Planerat ' + lpTxt + ' kW, tillåtet ' + n1(allowed) + ' kW' + par + '.') }; } /* Ringens färg per kvart när skyddet klämt (Claude Code 20/9: limit ligger på quarterly_decisions[]-raden för dagens kvartar t.o.m. nu, alltid null i planen). Stoppad laddning (allowed 0 / overload) ritas som Vänta, inte som laddning; begränsad (>0) behåller laddfärgen och markeras med en prick. ⇒ { color, marked } eller null när inget att ändra. */ function gcLimitRing(mode, lim, modes){ const l = gcLimit(mode, lim); if (!l) return null; return l.paused ? { color: (modes || GC_MODES).vanta.color, marked: false } : { color: null, marked: true }; } function gcReason(reason, fallback){ if (!reason || GC_DEBUG_REASON.test(reason)) return fallback; /* Backendens kurerade meningar bär punkt-decimal ("elen värd 1.28 kr mer"); hela UI:t i övrigt skriver svenskt komma. Normalisera BARA tal-punkt-tal — aldrig klockslag (de skrivs med kolon) eller andra punkter. */ return reason.replace(/(\d)\.(\d)/g, '$1,$2'); } /* Sparväg för läge Ny: hela settings-dokumentet (GET → ändra → POST). PATCH /api/settings tar bara ev_session. Dokumentet bär settings_rev sedan 16/8 — det åker med automatiskt eftersom vi postar tillbaka hela svaret, och avviker det svarar backend 409 utan att skriva. Då läser vi om, applicerar ändringen på det FÄRSKA dokumentet och postar en gång till. Mutatorn måste därför vara en funktion, inte ett färdigt objekt. */ const GC_SETTINGS_BASE = window.HEMS_BASE(); /* source: var sparningen gjordes, som rubriken X-HEMS-Source (K8, 4/10). */ async function gcSaveSettings(mutate, source, _retry){ const tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hems_session_token')) || ''; if (!tok) throw new Error('ingen session'); const url = `${GC_SETTINGS_BASE}/settings/api/${tok}`; const cur = await fetch(url, { headers:{ 'Accept':'application/json' } }) .then(r => { if (!r.ok) throw new Error('GET ' + r.status); return r.json(); }); const body = mutate(cur); // slot kommer i GET och skickas aldrig tillbaka (identitetsläget 3/10). if (body && Array.isArray(body.ev_chargers) && window.evModel && window.evModel.stripSlot) body.ev_chargers = body.ev_chargers.map(window.evModel.stripSlot); const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/json', ...((typeof source === 'string' && source) ? { 'X-HEMS-Source': source } : {}) }, body: JSON.stringify(body) }); if (res.status === 409){ /* 409 med annan kod än settings_rev_conflict: serverns text, inget nytt försök (controller_box_list_locked är borttagen 4/10). */ const j = await res.json().catch(() => null); const d = j && j.detail; if (d && typeof d === 'object' && !Array.isArray(d) && d.error && d.error !== 'settings_rev_conflict'){ const e = new Error(String(d.message || 'Kunde inte spara')); e.status = 409; e.code = d.error; e.serverText = true; throw e; } if (_retry) throw new Error('Inställningarna ändrades av någon annan — läs in sidan igen'); return gcSaveSettings(mutate, source, true); } /* 422 är backendens VALIDERINGSSVAR, inte ett tekniskt fel: body bär {detail:[{loc,msg,type}]} med svensk text och en fältpekare. Kastas den bort blir enda återkopplingen "kunde inte spara", och användaren får leta själv. Felet bär därför status + body vidare till vyn. */ if (res.status === 422){ const body = await res.json().catch(() => null); const e = new Error('POST 422'); e.status = 422; e.body = body; throw e; } if (!res.ok) throw new Error('POST ' + res.status); // Svaret bär ett nytt settings_rev. Vi sparar det inte: varje sparning // börjar med en färsk GET, så numret hämtas om ändå. Skulle den ordningen // ändras måste numret tas emot här. return res.json().catch(() => ({ ok:true })); } /* Delar en dygnsserie i sammanhängande körningar med KÄNT värde, så en kurva kan ritas med hål. Saknad prognos ritad som nolla läses som "solen tog slut" medan sanningen är "vi vet inte" — Lennie 18/8. Ligger här och inte i vyerna: samma regel gäller hjulet, schemat och allt som kommer efter. */ function gcRuns(d, get){ const runs = []; let cur = null; (d || []).forEach((x) => { const v = get(x); if (v == null || Number.isNaN(v)){ cur = null; return; } if (!cur){ cur = []; runs.push(cur); } cur.push({ x, v }); }); return runs; } /* Texten om upplösningen. Vid NU visar sol- och förbrukningscellerna MÄTVÄRDET (NU-fixen tidigare i sessionen), inte timmens prognos — då får raden inte påstå att talet ovanför gäller hela timmen. En regel, inte en kopia per vy. */ function gcFcNote(s){ if (!s || s.fcHourly !== true) return null; const live = s.isNow && (s.solLive != null || s.consLive != null); return live ? 'Sol och förbrukning visar mätvärdet just nu — prognosen är per timme. Priset är per kvart.' : 'Sol och förbrukning är timprognoser — samma värde i timmens fyra kvartar. Priset är per kvart.'; } /* Utjämning och rundade hörn — exakt som gamla klockan (schema-clock.jsx:92, 105). Prognosen är TIMVIS, så en rak linje mellan kvartarna ger fyra platåer med skarpa hörn; det var det Lennie såg som "hackigt". Gamla klockan gjorde två saker: 3-punkts glidande medelvärde (0,25/0,5/0,25) och kvadratiska Bézier genom punkternas mittpunkter. Utjämningen sker INOM en körning. Den får aldrig brygga över en kvart utan prognos — då fyller vi ett hål med ett påhittat värde, vilket är precis felet vi tog bort i dag. */ function gcSmoothRun(run){ return run.map((p, i) => { const a = run[Math.max(0, i-1)].v, b = p.v, c = run[Math.min(run.length-1, i+1)].v; return { x: p.x, v: a*0.25 + b*0.5 + c*0.25 }; }); } function gcRadialPath(pts, closed){ const N = pts.length; if (N < 2) return ''; const f = (n) => n.toFixed(2); let d = 'M ' + f(pts[0][0]) + ' ' + f(pts[0][1]); const last = closed ? N : N - 1; for (let i = 1; i <= last; i++){ const p0 = pts[i-1], p1 = pts[i % N]; d += ' Q ' + f(p0[0]) + ' ' + f(p0[1]) + ' ' + f((p0[0]+p1[0])/2) + ' ' + f((p0[1]+p1[1])/2); } return closed ? d + ' Z' : d; } /* Morgondagens elpris släpps runt 13:00 CET. Innan dess planerar backend på DAGENS priser som proxy — talen ligger i payloaden och är byte-identiska med idag (verifierat 18/8: kvart 96–191 = kvart 0–95). Ett sådant tal får inte visas som pris NÅGONSTANS: varken som linje, markörprick, mittsiffra, ringspik eller skalans maxvärde. En not som säger "preliminärt" under en exakt siffra räcker inte — siffran läses ändå. i = kvartindex i en 48h-serie; utan i avgör prelim ensam (vyer som redan vet att de visar morgondagen). */ const GC_Q_PER_DAY = 96; function gcPriceUnknown(prelim, i){ if (prelim !== true) return false; return i == null || i >= GC_Q_PER_DAY; } const GC_PRICE_UNRELEASED = 'Morgondagens elpris släpps runt kl 13:00. Planen är byggd på dagens priser som proxy.'; /* Vyer som hämtar egen data monteras INNAN bootHems hunnit sätta sessionsnyckeln. hems.js kastar då ett fel med noKeyYet — ett övergående tillstånd, inte ett fel. Skrivs det som fel står "kunde inte hämta" kvar permanent trots att datan finns; det syntes i statistikvyn 17/8 och i Prognoser 18/8. Väntar och försöker igen i stället. Returnerar en avbrytare för useEffect-städningen. */ function gcFetchReady(call, { onOk, onFail, tries = 40, delay = 250 }){ let dead = false, n = 0; const go = () => { if (dead) return; Promise.resolve().then(call).then( (d) => { if (!dead) onOk(d); }, (e) => { if (dead) return; if (e && e.noKeyYet && ++n < tries) { setTimeout(go, delay); return; } onFail(e); }); }; go(); return () => { dead = true; }; } /* Nodikoner för energiflödet. GcNodeIcon RITAR INGENTING själv — den positionerar och skalar projektets befintliga ikonuppsättning (window.Icon i bits.jsx, samma glyfer som navigering och kortrubriker använder). Ett första försök ritade fem egna glyfer här: då fick Elnät en snedställd hash medan navigeringen hade en ortogonal, och Hemmet tappade dörren. Två glyfvokabulär för samma sak i samma produkt, alltså exakt den sortens dubblering det här flödet redan råkat ut för. Wrappern bor i gc-core eftersom flödet finns i TVÅ kopior (gc-oversikt i läge Ny, flow-v2 fristående + gamla designen) och de har drivit isär en gång på batteriets huvudtal. window.Icon läses i renderkroppen, inte på modulnivå: bits.jsx laddas EFTER gc-core.jsx, så referensen finns inte när den här filen körs. */ const GC_NODE_ICON = { sol:'sun', home:'home', bat:'battery', ev:'car', grid:'grid', load:'thermo' }; function GcNodeIcon({ kind, x, y, s = 1, col = '#94a3b8', glow = null, fill = null }){ const name = GC_NODE_ICON[kind]; if (!name || typeof window.Icon !== 'function') return null; /* 24 enheter i rutan; s=1 ⇒ 24 px glyf centrerad på (x,y). Nästlad är giltig inuti SVG och ger oss bits.jsx-markupen orörd. */ const d = 24 * s; return ( /* glow 0–1 (Lennie 14/9): solen lyser i takt med produktionen. 0 ⇒ släckt (grå, dämpad), 1 ⇒ full färg + sken. */ 0 ? { filter: `drop-shadow(0 0 ${(2 + 6*glow).toFixed(1)}px ${col})` } : undefined}> ); } /* ── BETA-märkning (André 7/9) ─────────────────────────────────────────────── Skjutbara laster och effektavgift är byggda men tunt testade av oss. Bärgas av en pill vid rubriken och en notis i kortet: pillen ensam säger "nytt", inte "kontrollera efter", och det är det senare användaren behöver veta. Amber, samma färg som datavarningarna — ett förbehåll, inte ett fel. */ const GcBeta = ({ title }) => ( beta ); const GcBetaNote = ({ children }) => (
Beta {children}
); /* ── Dagens utfall mot prognos (flyttad hit 21/9) ────────────────────────── Låg i gc-prognoser.jsx. Schema-rutorna (v517) kopierade bara mk()-delen och hoppade över den ärliga solprognosen, med följden att rutan sa "+0,2 mot förväntat" medan kortet sa "−4,1 mot grundprognosen" på samma skärm — exakt den bugg SPEC 2026-08-26 skrevs för att döda. Nu läser BÅDA vyerna samma funktion, så de inte kan glida isär igen. · cons: utfall och prognos summeras över SAMMA förflutna fönster — hela kvartar före nu plus den förflutna andelen av nu-kvarten. Annars kan de inte jämföras. · sol: prognos och utfall är TVÅ serier och jämförs som två. day[].solPlan är den MERGADE serien (passerade kvartar överskrivna med utfall), så act − base kollapsar mot noll av konstruktion. Därför den ärliga grenen nedan när day.solarHonest finns. */ function gcDayTotals(day, nowIdx){ if (!Array.isArray(day) || !day.length) return null; const N = Math.min(day.length, GC_Q); const sum = (arr, f) => arr.reduce((a,x) => a + (f(x) || 0), 0); const done = day.slice(0, N).filter(x => x.solAct != null || x.consAct != null); const cur = Math.max(0, Math.min(nowIdx ?? 0, N - 1)); const p = day[cur].actPartial ?? 1; const elapsed = (f) => sum(day.slice(0, cur), f) + (f(day[cur]) || 0)*p; const r1 = (v) => Math.round(v*10)/10; const mk = (planF, actF) => { const plan = sum(day.slice(0, N), x => planF(x)*0.25); if (!done.length) return { plan, act:null, diff:null }; const base = elapsed(x => planF(x)*0.25); const act = elapsed(x => (actF(x) ?? 0)*0.25); return { plan: r1(plan), act, base, rest: Math.max(0, r1(plan) - r1(base)), diff: r1(act) - r1(base) }; }; const base = { sol: mk(x => x.solPlan || 0, x => x.solAct), cons: mk(x => (x.consPlanTot ?? x.consPlan ?? 0), x => x.consAct), doneN: done.length, }; /* ÄRLIG SOLPROGNOS (SPEC 2026-08-26). · hittills = MÄTAREN (live solar_today_kwh, null-bevarande) — samma tal som app-headern och Flöde; Σ actual som fallback · kvar = Σ forecast_pure från och med PÅGÅENDE timme, minus det som redan mätts i den (klampat 0) — ingen timme mellan stolarna · i morse = Σ frozen_day_hourly, 06-ankaret som aldrig rör sig intradag · diff = (hittills + kvar) − i morse, ur AVRUNDADE tal frozen=null (ny user utan ledger) ⇒ morgonrad + diff döljs, resten visas. Saknas honest helt ⇒ exakt beräkningen ovan. */ const H = day.solarHonest; if (H && Array.isArray(H.actual)){ const nowH = Math.floor(cur/4); const meas = H.actual.map((v,h) => ({v,h})).filter(x => x.v != null); const hittMeas = meas.reduce((a,x) => a + x.v, 0); /* HUVUDTALET ÄR MÄTAREN (SPEC 2026-08-27): honest.actual_hourly släpar 1–2 h. solar_today_kwh är samma tal som app-headern och Flöde visar. Fallback: mätare → Σ actual (mätta) → prognos (act=null). */ const liveKwh = day.solarLiveKwh; const hitt = liveKwh ?? (meas.length ? hittMeas : null); const hasPure = Array.isArray(H.pure); let kvar = null; if (hasPure){ const kvarEfter = H.pure.slice(nowH+1).reduce((a,v) => a + (v||0), 0); const extra = hitt != null ? Math.max(0, hitt - hittMeas) : 0; kvar = kvarEfter + Math.max(0, (H.pure[nowH] || 0) - extra); } const morgon = Array.isArray(H.frozen) ? H.frozen.reduce((a,v) => a + (v||0), 0) : null; const frozenElapsed = morgon != null ? meas.reduce((a,x) => a + (H.frozen[x.h]||0), 0) : null; const totPure = hasPure ? H.pure.reduce((a,v) => a + (v||0), 0) : null; const dygnsdiff = (hitt != null && kvar != null && morgon != null) ? r1(r1(hitt) + r1(kvar) - r1(morgon)) : null; base.sol = { honest: true, act: hitt != null ? r1(hitt) : null, rest: kvar != null ? r1(kvar) : null, plan: morgon != null ? r1(morgon) : (totPure != null ? r1(totPure) : null), morgon: morgon != null ? r1(morgon) : null, diff: dygnsdiff, diffSub: (morgon != null && meas.length) ? r1(r1(hittMeas) - r1(frozenElapsed)) : null, acc: H.accuracy || null, }; } return base; } /* Utfällbart kort: rubriken (och det som skickas in som head) syns alltid, resten ligger bakom ett klick. Används av NU-vyns kort — Dagens plan, HEMS beslut just nu och Prognoser (André 21/9) — som annars fyller flera skärmhöjder på telefon. Stängd som standard; hela huvudraden är knappen och chevronen visar läget. shell byter kortskalet (beslutskortet har egen ram och gradient), order sätter flex-ordningen i gco-wrap. */ /* Fångad på MODULNIVÅ, inte i komponenten: ui-prefs.js laddas före den här filen, och en villkorlig hook-anropskedja som kunde byta mellan renderingar är precis den hook-ordningskrasch filen varnar för högst upp. */ const GC_FOLD_PREF = typeof window !== 'undefined' ? window.useFoldPref : null; /* summary (v699, André 27/9): visas under head bara när kortet är ihopfällt. */ function GcFold({ label, right, head, summary, order, shell, prefKey, defaultOpen, children }){ /* Läget sparas per kort (André 21/9: alla tittar på olika saker). Utan prefKey faller kortet tillbaka på lokal state — ett kort utan eget id får inte dela läge med ett annat. */ const fold = GC_FOLD_PREF ? GC_FOLD_PREF('gcfold.' + (prefKey || '_anon'), !!defaultOpen) : null; const local = React.useState(!!defaultOpen); const useSaved = !!(fold && prefKey); const open = useSaved ? fold[0] : local[0]; const setOpen = useSaved ? fold[1] : local[1]; /* px-3 på telefon, px-4 från 640 px (André 24/9: "sidan på mobil kan använda bredden lite mera, inte mycket"). Gäller alla fällkort. */ const cls = shell || 'rounded-2xl border border-slate-800 bg-slate-900/40 px-3 py-3.5 sm:px-4'; const st = order == null ? undefined : { order }; return (
{open && children}
); } /* Manuell körning av smartlast (§23, André 26/9). Knapparna finns bara när backend svarar på /api/load-manual (useLoadManual ger null annars ⇒ knapparna dämpade, "Kommer snart"). Två lägen (André 26/9): 'on' = på hela tiden tills kunden stänger av eller dygnet byter; 'solar' = bara när det finns solöverskott KVAR efter bil och planerade smartlaster (sist i kön), också till midnatt. Planen räknas om av backend; dygnet räknas inte in i snittet. compact = i Schema-detaljen. Bekräftelsen är en rad i kortet, ingen webbläsarruta (André 26/9). */ function GcManualRun({ loadId, name, compact, bare, inline }){ const [m, set] = window.useLoadManual ? window.useLoadManual(true) : [null, null]; const [busy, setBusy] = React.useState(false), [err, setErr] = React.useState(null), [ask, setAsk] = React.useState(null); const [offAt, setOffAt] = React.useState(null); React.useEffect(() => { if (!offAt) return; const t = setTimeout(() => setOffAt(null), 100000); return () => clearTimeout(t); }, [offAt]); const st = window.lwManualFor ? window.lwManualFor(m, loadId) : null; if (loadId == null || !window.useLoadManual) return null; /* Knapparna visas alltid (André 26/9: "det skall synas"). Utan backend står de dämpade med "Kommer snart". */ const ready = !!(m && set); const on = !!(st && st.on); const mode = on ? (st.mode === 'solar' ? 'solar' : 'on') : null; const hm = (t) => { if (t == null) return ''; const d = new Date(Number(t) * (Number(t) < 1e12 ? 1000 : 1)); return isNaN(d) ? '' : String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }; const send = async (want, turnOff) => { setBusy(true); setErr(null); setAsk(null); try { await set(loadId, !turnOff, want); setOffAt(turnOff ? Date.now() : null); } catch (e) { setErr(e && e.message && !/^HTTP /.test(e.message) ? e.message : 'Gick inte att ändra. Försök igen.'); } setBusy(false); }; const go = (want) => { if (busy) return; if (mode === want) return send(want, true); setAsk(ask === want ? null : want); }; const since = st && st.since ? hm(st.since) : ''; const step = window.lwManStep ? window.lwManStep(st) : (since ? 'sedan ' + since : ''); const offSentHM = st && !on && st.sent_at != null && st.until != null && Number(st.sent_at) >= Number(st.until) - 1 ? hm(st.sent_at) : null; const sz = compact ? 'h-7 px-1 text-[11px] whitespace-nowrap' : 'h-8 px-3 text-xs whitespace-nowrap'; /* Fast bredd och samma text i på- och av-läge, så knapparna inte flyttar sig när ett läge tänds (André 26/9). Läget står i Sedan-raden. */ const base = ' inline-flex items-center justify-center gap-1.5 rounded-lg border font-bold transition-colors disabled:opacity-50 outline-none focus:outline-none focus-visible:outline-none ' + (compact ? 'w-[76px] min-[380px]:w-[84px] ' : 'w-[104px] '); const off = 'border-slate-600 bg-slate-800/70 text-slate-100 hover:border-slate-400'; const txt = compact ? 'text-[11px]' : 'text-xs'; /* Backend 5/10: dygnets manuella energi (manual_kwh, alla tryckningar), inte bara senaste körningens kwh. */ const dayM = window.lwManualDay ? window.lwManualDay(st) : null; const dayKwh = dayM && dayM.kwh != null ? dayM.kwh : (st && st.kwh != null ? Number(st.kwh) : null); const kwhTxt = dayKwh != null ? ' · ' + window.gcNum(dayKwh, 1) + ' kWh' : ''; /* v676 (backend 27/9, manuell körning direkt): dygnet räknas bort ur snittet först efter 10 min manuell körning (day_on_sec > 600). Efter av-tryck: "Av skickat", vid off_fails 1–2 "Av skickat igen (n av 3)", vid off_deviation "Avstängd men drar ändå ström" i avvikelsefärg. Av-kontrollen tar ~90 s; texterna gäller 100 s efter trycket (lokalt eller until), avvikelsen så länge backend har den. */ const excl = !!(dayM ? dayM.onSec > 600 : st && Number(st.day_on_sec) > 600); const offMs = st && !on && st.until != null ? Number(st.until) * (Number(st.until) < 1e12 ? 1000 : 1) : null; const recentOff = (offAt != null && Date.now() - offAt < 100000) || (offMs != null && Date.now() - offMs >= 0 && Date.now() - offMs < 100000); const fails = st && !on ? Number(st.off_fails) || 0 : 0; const offTxt = st && !on && st.off_deviation ? 'dev' : recentOff ? (fails >= 1 && fails <= 2 ? 'Av skickat igen (' + fails + ' av 3)' : 'Av skickat' + (offSentHM ? ' ' + offSentHM : '')) : null; return (
{/* inline: båda lagren är display: contents så knappar, Sedan-rad och bekräftelse blir syskon till Körs och i-knappen i förälderns flex-rad. */}
{!ready && Kommer snart} {on && !bare && {mode === 'solar' ? 'Sol' : 'Manuellt'} på till midnatt{step ? ' · ' + step : ''}{kwhTxt}{excl ? ' · räknas inte in i snittet' : ''}} {on && bare && på till midnatt{step ? ' · ' + step : ''}} {offTxt && {offTxt === 'dev' ? Avstängd men drar ändå ström : offTxt}}
{ask && !on && (
{ask === 'solar' ? 'Går bara när sol blir över efter bilen och planerade laster, till midnatt.' : 'På hela tiden till midnatt.'} Körs den mer än 10 minuter räknas dagen inte in i inlärningen.
)} {err &&
{err}
}
); } Object.assign(window, { GcManualRun, GcFold, gcDayTotals, GC_IDLE_KW, GcNodeIcon, GcBeta, GcBetaNote, gcReason, gcLimit, gcLimitRing, gcSaveSettings, gcRuns, gcFcNote, gcPriceUnknown, GC_PRICE_UNRELEASED, GC_Q_PER_DAY, gcFetchReady, gcSmoothRun, gcRadialPath, GC_Q, GC_MODES, GC_EV_SRC_COL, GC_LOAD_COL, gcMixCol, gcLoadCol, GC_MONO, GC_LABEL, GC_CARD, GC_C, gcNum, gcLabel, gcRange, gcMode, gcRanges, gcLayout, gcBatStatus, gcBatName, });