/* ============================================================================ 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.' }, batexport: { label:'Sälj batteri', color:'#2dd4bf', desc:'Kvällens toppris — 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.' }, }; const GC_EV_SRC_COL = { sol:'#facc15', 'nät':'#60a5fa', batteri:'#fb923c' }; const GC_LOAD_COL = { sol:'#facc15', 'nät':'#60a5fa', batteri:'#fb923c', blandat:'#a3e635' }; 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){ const order = ['sol', ...Array.from({length:nBats},(_,k)=>'b'+k), 'grid', ...Array.from({length:nEvs},(_,k)=>'ev'+k).reverse(), 'hem']; const n = order.length; const r = n <= 5 ? 54 : n === 6 ? 50 : n === 7 ? 46 : n <= 9 ? 42 : 38; 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; }); return out; } /* Batteriets status i hans ordval. src kommer från backendens action. */ function gcBatStatus(bp){ if (bp == null || bp.p == null) return 'okänt'; if (bp.p > 0.05) return bp.src === 'sol' ? 'laddar · sol' : bp.src === 'nät' ? 'laddar · nät' : 'laddar'; if (bp.p < -0.05) return bp.src === 'export' ? 'säljer' : '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; 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(); async function gcSaveSettings(mutate, _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); const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(body) }); if (res.status === 409){ if (_retry) throw new Error('Inställningarna ändrades av någon annan — läs in sidan igen'); return gcSaveSettings(mutate, 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' }){ 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 ( ); } /* ── 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}
); Object.assign(window, { GcNodeIcon, GcBeta, GcBetaNote, gcReason, 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, GC_MONO, GC_LABEL, GC_CARD, GC_C, gcNum, gcLabel, gcRange, gcMode, gcRanges, gcLayout, gcBatStatus, gcBatName, });