/* app.jsx — main dashboard composition */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "scenario": "sunny", "density": "regular", "gravioV2": true, "showFlow": true, "showEV": true, "showSavings": true, "showDevices": true, "showActivity": true, "secondBattery": true, "laddbox": false, "twoCars": false, "evCharging": false, "lasterDemo": false, "peakLimit": 6, "accent": "blue", "playLive": true, "schemaView": "clock", "useLive": true, "apiKey": "", "c_kpi": false, "c_flow": false, "c_forecast": false, "c_schema": false, "c_plan": false, "c_planchart": false, "c_battery": false, "c_peak": false, "c_ev": false, "c_savings": false, "c_devices": false, "c_activity": false, "c_combo": false, "fc_solar": true, "fc_cons": true, "fc_solBands": false, "fc_consBands": false, "fc_soc": true, "fc_buy": true, "fc_sell": true }/*EDITMODE-END*/; const HEMS_BASE_URL = window.HEMS_BASE(); /* Collapsible wrapper — hides everything in the card except the first child (head row). Adds a chevron toggle button in the upper-right corner of the card. */ const Collapsible = ({ id, cardId, collapsed, onToggle, label, children }) => (
{children}
); const ACCENTS = { blue: { blue:'#5BA8FF', violet:'#9D7BFF' }, green: { blue:'#3FCC8B', violet:'#7CE2C8' }, amber: { blue:'#F4C84B', violet:'#FF8A47' }, }; const TopBar = ({ now, scenario, onScenario, liveStatus, home, v2, onDesign }) => { const sc = SCENARIOS[scenario]; const hh = String(now.getHours()).padStart(2,'0'); const mm = String(now.getMinutes()).padStart(2,'0'); const dayName = ['Sön','Mån','Tis','Ons','Tor','Fre','Lör'][now.getDay()]; const monthName = ['jan','feb','mars','apr','maj','juni','juli','aug','sep','okt','nov','dec'][now.getMonth()]; const dateStr = `${dayName} ${now.getDate()} ${monthName}`; let statusColor = 'var(--c-sell)', statusText = 'System optimerar · sparar just nu'; if (liveStatus){ if (liveStatus.kind === 'loading'){ statusColor = 'var(--c-unchanged)'; statusText = 'Ansluter till HEMS...'; } else if (liveStatus.kind === 'live'){ statusColor = 'var(--c-sell)'; statusText = `Live · ${liveStatus.message || 'ansluten'}`; } else if (liveStatus.kind === 'warning'){ statusColor = 'var(--c-unchanged)'; statusText = `Live · ${liveStatus.message || 'varning'}`; } else if (liveStatus.kind === 'error'){ statusColor = 'var(--c-peak)'; statusText = `Live offline · ${liveStatus.message || 'mockdata'}`; } else if (liveStatus.kind === 'mock'){ statusColor = 'var(--ink-2)'; statusText = 'Mockdata'; } } return (
Gravio Gravio {statusText} {/* Designväxel — gammal vs ny dashboard. Länkarna är RIKTIGA hrefs med ?design=, så cmd/mellanklick öppnar det andra läget i en egen flik och de två kan stå bredvid varandra. Vanligt klick byter på plats utan omladdning. */} {onDesign && ( { if(e.metaKey||e.ctrlKey||e.shiftKey||e.button!==0) return; e.preventDefault(); onDesign(false); }}>Gammal { if(e.metaKey||e.ctrlKey||e.shiftKey||e.button!==0) return; e.preventDefault(); onDesign(true); }}>Ny )}
{dateStr} {hh}:{mm}
); }; const Side = ({ home }) => { const name = home?.name || 'Demo-hem'; const area = home?.area || 'SE3'; const battKwh = home?.batteryCapacityKwh || 13.5; const solarKwp = home?.solarKwp || 4.8; return ( ); }; const KPIs = ({ cur, scenario, soc, liveSnapshot }) => { const sc = SCENARIOS[scenario]; const meta = ACTION_META[cur.action]; // Prefer live values where present const consumption = liveSnapshot ? (liveSnapshot.home_consumption || 0)/1000 : cur.consumption; const solarTodayKwh = liveSnapshot?.solar_today_kwh != null ? liveSnapshot.solar_today_kwh : sc.prodToday; const updatedSec = liveSnapshot ? Math.round(liveSnapshot.age_sec || 0) : null; // Kort "varför"-förklaring — samma källa som mobilhjälten (live.reason bär // riktiga siffror; filtrera bort pipeline-trace; falla tillbaka på malltext). const _curReason = window.gcReason(cur.reason, ''); const reasonText = (liveSnapshot && liveSnapshot.reason) || _curReason || (meta && meta.desc); return (
Solproduktion idag
{solarTodayKwh.toFixed(1).replace('.',',')}kWh
{liveSnapshot ? `${((liveSnapshot.solar_power||0)/1000).toFixed(1).replace('.',',')} kW just nu` : '23% över prognos'}
Spotpris just nu
{cur.buy.toFixed(0)}öre/kWh
{(() => { const dc = liveSnapshot?.decision_context; if (dc && dc.current_buy_kr != null && dc.daily_avg_buy_kr != null){ const diffOre = Math.round((dc.current_buy_kr - dc.daily_avg_buy_kr) * 100); if (Math.abs(diffOre) < 1) return
vid dygnssnittet
; const under = diffOre < 0; return
{Math.abs(diffOre)} öre {under?'under':'över'} snitt
; } return
spotpris
; })()}
Effekt hemmet
{consumption.toFixed(1).replace('.',',')}kW
{liveSnapshot ? <> uppdaterad {updatedSec}s sedan : <> 5 enheter aktiva}
Status
{meta.label}
{reasonText &&
{reasonText}
} {(() => { // Recommended power = HEMS command magnitude. For multi-battery // systems we show it PER battery (per_battery_actions[].power_kw is // the per-battery command; AI-controlled batteries aren't commanded). const perBatt = Array.isArray(liveSnapshot?.per_battery_actions) ? liveSnapshot.per_battery_actions : null; const dischargeActions = new Set(['selfconsumption','export','peakshaving','discharge']); const signedCmd = (b) => { const mag = Math.abs(b?.power_kw ?? 0); return dischargeActions.has(b?.action) ? -mag : mag; }; const fmtKw = (kw) => { const abs = Math.abs(kw); const sign = kw > 0.005 ? '+' : kw < -0.005 ? '−' : ''; const color = kw > 0.005 ? 'var(--c-sell)' : kw < -0.005 ? 'var(--c-cover)' : 'var(--ink-2)'; return { txt: `${sign}${abs.toFixed(2).replace('.',',')} kW`, color }; }; // Multi-battery → one row per battery if (perBatt && perBatt.length > 1){ return (
Rekommenderad effekt
{perBatt.map((b, i) => { const label = b?.battery ? `Batteri ${b.battery}` : `Batteri ${i+1}`; if (b?.ai_controlled){ return (
{label} AI-styrt
); } const f = fmtKw(signedCmd(b)); return (
{label} {f.txt}
); })}
); } // Single battery (or no per-battery data) → single line, as before const recKw = (perBatt && perBatt.length === 1) ? signedCmd(perBatt[0]) : (liveSnapshot?.power_kw != null ? liveSnapshot.power_kw : (liveSnapshot ? (liveSnapshot.battery_power||0)/1000 : null)); if (recKw == null) return (
Plan följs · 5 min sedan
); const f = fmtKw(recKw); return (
{f.txt} rekommenderad effekt
); })()}
); }; /* ---------- Tweaks panel ---------- */ const TweaksUi = ({ t, setTweak }) => ( setTweak('useLive', v)} /> setTweak('apiKey', v)} /> setTweak('scenario', v)} /> setTweak('playLive', v)} /> setTweak('secondBattery', v)} /> setTweak('laddbox', v)} /> setTweak('twoCars', v)} /> setTweak('evCharging', v)} /> setTweak('lasterDemo', v)} /> setTweak('peakLimit', v)} /> setTweak('schemaView', v)} /> setTweak('density', v)} /> { if (window.__hemsSetDesign) window.__hemsSetDesign(v); else setTweak('gravioV2', v); }} /> setTweak('showFlow', v)} /> setTweak('showSavings', v)} /> setTweak('accent', v)} /> ); /* ---------- App ---------- */ function App(){ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); /* Designläget sätts per FLIK med ?design=ny|gammal, så gammal och ny dashboard kan stå öppna bredvid varandra i två fönster. Tweaken är utgångsläget för en flik UTAN parameter; finns parametern vinner den. Växlaren skriver båda, så de aldrig kan säga olika saker. */ const [designQ, setDesignQ] = React.useState(() => { const q = new URLSearchParams(location.search).get('design'); if (q === 'ny') return true; if (q === 'gammal') return false; try { const s = localStorage.getItem('hems.design'); if (s === 'ny') return true; if (s === 'gammal') return false; } catch(e){} return null; }); const v2 = designQ == null ? t.gravioV2 : designQ; const setDesign = React.useCallback((next) => { const u = new URL(location.href); u.searchParams.set('design', next ? 'ny' : 'gammal'); history.replaceState(null, '', u); try { localStorage.setItem('hems.design', next ? 'ny' : 'gammal'); } catch(e){} setDesignQ(next); setTweak('gravioV2', next); }, [setTweak]); // Tweak-panelen ligger i en annan komponent och når inte setDesign direkt. React.useEffect(() => { window.__hemsSetDesign = setDesign; return () => { delete window.__hemsSetDesign; }; }, [setDesign]); // Time source: real wall-clock when live, frozen scenario time when mock-only const [now, setNow] = React.useState(() => t.useLive ? window.hemsDate() : new Date('2026-05-26T19:34:00') ); const [scrub, setScrub] = React.useState(null); // Dashboard content tabs (UX 2026-06-04): Översikt visar det viktigaste, // sekundärt (Prognos / Ekonomi) på egna flikar för en lugnare startvy. const [dashTab, setDashTab] = React.useState('oversikt'); // Live data state const [liveSlots, setLiveSlots] = React.useState(null); const [livePayloads, setLivePayloads] = React.useState({ live:null, peak:null, settings:null, savings:null, quarterHistory:null, dataStatus:null, history:null }); const [liveStatus, setLiveStatus] = React.useState({ kind:'loading' }); // Omhämtning av settings direkt efter en sparning i EV-kortet — utan den // väntar kortet upp till 120 s på nästa schedule-poll och visar under tiden // den gamla laddarkonfigurationen (2026-08-08). const refreshSettingsNow = React.useCallback(async () => { try { const settings = await window.hems.getSettings(); setLivePayloads(p => ({ ...p, settings })); } catch (e) { console.error('settings refresh failed', e); } }, []); // Per-stream refresh meta: pulse animation key + timestamp of last successful fetch. // Used so cards can flash a "just updated" indicator + show "X min sedan". const [refreshMeta, setRefreshMeta] = React.useState({ schedule: { count: 0, at: Date.now() }, live: { count: 0, at: Date.now() }, }); // Force a re-render every 10s so "uppdaterad för X min sedan" stays current // even when no new poll has arrived. const [, forceTick] = React.useState(0); React.useEffect(() => { const id = setInterval(() => forceTick(t => t+1), 10000); return () => clearInterval(id); }, []); // Boot HEMS + mock sample React.useEffect(()=>{ let stopFns = []; let cancelled = false; async function boot(){ if (!window.hems || !window.hemsAdapter) return; // Säkerhetsfix 2026-05-29: STRIKT sess_-validering. Acceptera ENDAST // riktiga session-tokens från login (sess_*). Gamla hårdkodade hems_*- // nycklar från tidigare cutover-fönster rensas → tvingar fram login. // Ignorerar t.apiKey helt (tweak-overrides är dev-only, inte säkra). let sessionToken = (typeof localStorage !== 'undefined') ? localStorage.getItem('hems_session_token') : null; if (sessionToken && !sessionToken.startsWith('sess_')) { // Spöke från tidigare cutover — rensa och tvinga login localStorage.removeItem('hems_session_token'); sessionToken = null; } if (!sessionToken && t.useLive) { window.location.href = 'login.html'; return; } const apiKey = sessionToken || ''; await window.hemsAdapter.bootHems({ baseUrl: HEMS_BASE_URL, apiKey, useMock: !t.useLive, sampleUrl: 'hems-sample.json', }); setLiveStatus({ kind: t.useLive ? 'loading' : 'mock' }); // Nyckeln är satt först här. Vyer som hämtar på egen hand (statistikens // jämförelse) måste vänta på detta — annars går anropet med tom api_key. setHemsReady(true); const refreshSchedule = async () => { try { const [sched, hist, settings] = await Promise.all([ window.hems.getSchedule(), window.hems.getHistory(7).catch(()=>({consumption:[]})), window.hems.getSettings().catch(()=>null), ]); if (cancelled) return; const slots = window.hemsAdapter.buildSlots(sched, hist, settings); setLiveSlots(slots); // #60: plan_explanation (färdig "därför"-mening från backend) följer // med rå-analysen — spara payloaden så PlanWhy kan läsa den. setLivePayloads(p => ({ ...p, settings, analysis: sched, history: hist })); setRefreshMeta(m => ({ ...m, schedule: { count: m.schedule.count + 1, at: Date.now() } })); } catch (e){ console.error('schedule fetch failed', e); if (!cancelled) setLiveStatus({ kind:'error', message: e.message.slice(0,80) }); } }; const refreshLive = async () => { try { const live = await window.hems.getLive(); if (cancelled) return; setLivePayloads(p => ({ ...p, live })); setRefreshMeta(m => ({ ...m, live: { count: m.live.count + 1, at: Date.now() } })); setLiveStatus(s => s.kind==='error' ? s : { /* Åldern räknas ur live.timestamp, inte ur age_sec: /api/live-data cachar svaret 150 s server-side och age_sec fryser med cachen (uppmätt 71,6 s mot verklig ålder 209,3 s). Ligger kvar även efter backends fix — timestamp mot klockan är sanningen. */ kind: live.connected ? 'live' : 'warning', message: live.connected ? `uppdaterad ${(() => { const ts = live.timestamp ? Date.parse(live.timestamp) : NaN; const sec = ts ? Math.max(0, Math.round((Date.now() - ts)/1000)) : Math.round(live.age_sec || 0); return sec < 90 ? `${sec}s` : `${Math.round(sec/60)} min`; })()} sedan` : 'frånkopplad', }); } catch (e){ console.warn('live fetch failed', e); if (!cancelled) setLiveStatus({ kind:'error', message:'mockdata' }); } }; const refreshPeak = async () => { try { const peak = await window.hems.getPeak(); if (!cancelled) setLivePayloads(p => ({ ...p, peak })); } catch(e){ /* ignore */ } }; const refreshSavings = async () => { try { const savings = await window.hems.getSavings(); if (!cancelled) setLivePayloads(p => ({ ...p, savings })); } catch(e){ /* ignore */ } }; // /api/data-status → connection + warnings[] (sensorvarningar + batteri-pausad) const refreshDataStatus = async () => { try { const ds = await window.hems.getDataStatus(); if (!cancelled) setLivePayloads(p => ({ ...p, dataStatus: ds })); } catch(e){ /* ignore */ } }; // /api/quarter-history → per-quarter action breakdown (sub-quarter mixes for past). const refreshQuarterHistory = async () => { try { const data = await window.hems.getQuarterHistory(); if (!cancelled) setLivePayloads(p => ({ ...p, quarterHistory: data })); // Diagnostic — confirm payload shape against Anders' spec const q = data?.quarters || {}; const sampleKey = Object.keys(q)[0]; const mixedKeys = Object.keys(q).filter(k => q[k]?.has_mixed).slice(0, 5); console.log('[quarter-history] keys:', Object.keys(q).length, 'sample key:', sampleKey, 'sample:', q[sampleKey], 'mixed quarters:', mixedKeys); } catch(e){ console.warn('quarter-history fetch failed', e); } }; await Promise.all([refreshSchedule(), refreshLive(), refreshPeak(), refreshSavings(), refreshQuarterHistory(), refreshDataStatus()]); // Polling — frikopplade intervall per endpoint (DESIGN_STATE 7c). // Takterna bor i hems.js POLL_INTERVAL_MS; 7/9 gick allt utom live till 300s. stopFns.push(window.hems.poll('live', refreshLive)); // 10s stopFns.push(window.hems.poll('schedule', refreshSchedule)); // 300s stopFns.push(window.hems.poll('peak', refreshPeak)); // 300s stopFns.push(window.hems.poll('savings', refreshSavings)); // 300s stopFns.push(window.hems.poll('quarterHistory', refreshQuarterHistory)); // 300s stopFns.push(window.hems.poll('dataStatus', refreshDataStatus)); // 60s } boot(); return () => { cancelled = true; stopFns.forEach(fn => { try { fn(); } catch(e){} }); }; }, [t.useLive, t.apiKey]); // Mock slots (always available as fallback / when not live) const mockSlots = React.useMemo( () => makeSchedule(t.scenario.length * 17 + 3, t.scenario), [t.scenario] ); const slots = (t.useLive && liveSlots && liveSlots.length === 192) ? liveSlots : mockSlots; // Compute "now" quarter index of today const nowQ = Math.min(95, Math.floor(now.getHours()*4 + now.getMinutes()/15)); const activeIdx = scrub != null ? scrub : nowQ; let cur = slots[activeIdx]; // If we have a live snapshot, overlay it onto cur (only when not scrubbing) if (t.useLive && livePayloads.live && scrub == null){ cur = window.hemsAdapter.buildLiveCur(livePayloads.live, slots, now); } const soc = cur.soc; // Tick clock: real wall-clock if live, sped-up clock if mock+playLive React.useEffect(()=>{ if (t.useLive){ const id = setInterval(()=> setNow(window.hemsDate()), 1000); return () => clearInterval(id); } if (t.playLive){ const id = setInterval(()=>{ setNow(d => new Date(d.getTime() + 60_000)); }, 1000); return ()=>clearInterval(id); } }, [t.playLive, t.useLive]); // Apply accent React.useEffect(()=>{ const a = ACCENTS[t.accent]; document.documentElement.style.setProperty('--blue', a.blue); document.documentElement.style.setProperty('--violet', a.violet); }, [t.accent]); // Effective peak limit — live settings beats slider const effectivePeakLimit = livePayloads.settings?.peak_shaving_max_grid_kw ?? t.peakLimit; // Home metadata const home = window.hemsAdapter?.buildHome(livePayloads.settings) || null; // Första-laddning: i live-läge, innan första svaret landat, föll koden // tidigare tillbaka på mock-scenariot (soligt demo) i ~0,5 s → flash av // "främmande" data vid hard refresh. Gejta kort-ytan bakom en placeholder // tills vi fått riktig data (eller ett fel). const hasLiveData = !!(liveSlots || livePayloads.live); const booting = t.useLive && liveStatus.kind === 'loading' && !hasLiveData; /* Ny design (Gravio-porten). Byggs bara när växeln är på, och läser exakt samma payloads som resten av appen — ingen egen datahämtning. Gamla vyn ligger kvar och kommer tillbaka genom att slå av växeln. */ /* Statistikvyns spann (Dag/Vecka/Månad/År) styrs inifrån vyn — den anropar onRange, och vi hämtar /api/economics/comparison för just det spannet. Endpointen är dygnsbaserad: lagrade dygn går t.o.m. igår, dagens kräver today=1 och märks partial. */ const [hemsReady, setHemsReady] = React.useState(false); const [ecoRange, setEcoRange] = React.useState(null); const [ecoCmp, setEcoCmp] = React.useState(null); const onEcoRange = React.useCallback((from, to, includeToday) => { setEcoRange({ from, to, includeToday }); }, []); React.useEffect(() => { if (!hemsReady || !ecoRange || !window.hems?.getEconomicsComparison) return; let stop = false; const load = async () => { try { const data = await window.hems.getEconomicsComparison(ecoRange.from, ecoRange.to, ecoRange.includeToday); if (!stop) setEcoCmp(data); } catch (e) { if (e && e.noKeyYet) return; // körs om när nyckeln finns console.warn('economics/comparison failade', e); if (!stop) setEcoCmp({ days: [], totals: null }); } }; load(); /* Spann som innehåller dagens dygn växer under dagen. Backend cachar today=1 i 15 min (hems-staging-01374-leg), så tätare hämtning ger ändå samma svar — vi hämtar om var 15:e minut. Historiska spann är låsta och hämtas en gång. */ if (!ecoRange.includeToday) return () => { stop = true; }; const id = setInterval(load, 15 * 60 * 1000); return () => { stop = true; clearInterval(id); }; }, [hemsReady, ecoRange]); const gravio = React.useMemo(() => { if (!v2 || !window.buildGravioDay || !slots || slots.length < 96) return null; const opts = { analysis: livePayloads.analysis, history: livePayloads.history }; const units = window.gravioUnits(livePayloads.settings, livePayloads.live); const day = window.buildGravioDay(slots, livePayloads.live, evLoad, livePayloads.settings, 0, opts); const tomorrow = slots.length >= 192 ? window.buildGravioDay(slots, livePayloads.live, evLoad, livePayloads.settings, 1, opts) : null; return day ? { units, day, tomorrow } : null; }, [v2, slots, livePayloads.live, livePayloads.settings, livePayloads.analysis, livePayloads.history, evLoad]); // Elbil & skjutbara laster (SPEC_LASTER_EV §3): band-data till plan-grafen. // Live: reserverade fält ur /ml/analysis + settings (buildEvLoadPlan i // ev-loads.jsx — all fältinläsning samlad där tills backend-formen är fryst). // Saknas fälten (backend ej klar) → demo-plan ur priserna när tweaks är på. const evLoad = React.useMemo(() => { const real = (window.buildEvLoadPlan && livePayloads.analysis) ? window.buildEvLoadPlan(livePayloads.analysis, livePayloads.settings) : null; if (real) return real; if ((t.laddbox || t.lasterDemo) && window.demoEvLoadPlan){ return window.demoEvLoadPlan(slots, nowQ, { ev: t.laddbox, loads: t.lasterDemo }); } return null; }, [livePayloads.analysis, livePayloads.settings, t.laddbox, t.lasterDemo, slots, nowQ]); // EV-kort (§5 multi-EV): lista av laddboxar ur settings (evModel migrerar // gammalt singular-objekt). Mock-syntes för design-granskning: Laddbox-tweaken // = 1 bil, "2 bilar (demo)" = 2 bilar, när ingen riktig EV-data finns. const evSynth = (i) => ({ id: 'mock' + i, name: i === 0 ? 'Tesla' : 'Familjebilen', battery_kwh: 77, target_pct: 80, departure_time: i === 0 ? '07:00' : '08:30', phases: 3, min_current_a: 6, max_current_a: 16, variable_current: true, }); const chargers = React.useMemo(() => { const real = window.evModel ? window.evModel.normalize(livePayloads.settings) : []; if (real.length) return real; if (t.twoCars) return [evSynth(0), evSynth(1)]; if (t.laddbox) return [evSynth(0)]; return []; }, [livePayloads.settings, t.twoCars, t.laddbox]); const chargersAreMock = chargers.length > 0 && String(chargers[0].id || '').startsWith('mock'); const evNames = chargers.map((c, i) => window.evModel ? window.evModel.displayName(c, i) : (c.name || `Bil ${i+1}`)); // Mock-effekt per box (W): box0 följer "Bilen laddar nu", box1 laddar 3,6 kW. const evWList = chargersAreMock ? chargers.map((_, i) => i === 0 ? (t.evCharging ? 7000 : 0) : 3600) : undefined; const evMockFlow = chargersAreMock ? chargers.map((_, i) => ({ power_kw: (i === 0 ? (t.evCharging ? 7000 : 0) : 3600) / 1000, name: evNames[i] })) : undefined; const showEvCard = chargers.length > 0; // Next planned action description const planNext = React.useMemo(()=>{ for (let i=nowQ+1; i<96; i++){ if (slots[i].action !== cur.action){ return `${ACTION_META[slots[i].action].label} kl ${fmt.hhmm(i)}`; } } return '— inga ändringar idag'; }, [slots, nowQ, cur.action]); /* ===== Läge "Ny" = kollegans app ===================================== Inte våra vyer i vårt skal: hans header, hans flikrad, hans enkolumns- layout ersätter hela vårt skal. Datalagret ovanför är gemensamt — GcApp hämtar ingenting själv. Tweak-panelen följer med, annars går läget inte att granska. Gamla designen ligger orörd bakom växeln. */ if (v2 && window.GcApp) { const liveText = !t.useLive ? 'Demoläge · mockdata' : hasLiveData ? 'Live · ansluten' : 'Live · ansluter…'; /* Samma bootgejt som gamla designen (602-606) — v2-grenen saknade den, så vid hard refresh renderade GcApp mockscenariot i ~0,5–1 s tills första live-svaret landat: "helt annan data än ens egen" (André 31/8). Ingen data alls förrän riktiga siffror finns — bara en lugn laddyta. */ /* Gejten släppte för tidigt: hasLiveData blir sann när FÖRSTA svaret (ofta live-payloaden) landat, men slots-fallbacken är mock tills SCHEMAT (liveSlots) kommit — så GcApp hann rendera mockplan i någon sekund efter laddytan (André 31/8, andra varvet). v2 gejtar därför på liveSlots specifikt: det är schemat som föder day/tomorrow/graferna. Fel-läget (liveStatus error) släpps igenom — där är mock medvetet. */ if (booting || (t.useLive && !liveSlots && liveStatus.kind !== 'error')) { return (
Ansluter till HEMS…
Hämtar din data
); } return ( setDesign(false)} liveText={liveText} dataStatus={livePayloads.dataStatus} peak={livePayloads.peak} homeName={typeof home === 'string' ? home : (home && home.name) || 'Hem'}/> ); } return (
{/* Systemvarningar (#282) — läser warnings_structured om fältet finns ({text, severity, action}), annars fallback till warnings[] (strängar, renderas som tidigare via text-heuristik). Backend-text renderas rakt av — ingen tolkning eller omskrivning. severity 'critical' = röd tydlig banner; 'warning' = gul diskret. action 'settings' = klickbar → inställningssidan. */} {(() => { const ds = livePayloads.dataStatus; const structured = Array.isArray(ds?.warnings_structured) ? ds.warnings_structured : null; const ws = structured || ds?.warnings; if (!Array.isArray(ws) || ws.length === 0) return null; // Normalisera: element kan vara sträng (legacy warnings[]) eller // objekt {text, severity, action} (warnings_structured). const norm = (w) => typeof w === 'string' ? { text: w } : { text: w.text || w.message || '', severity: w.severity, action: w.action, kind: w.kind }; const items = ws.map(norm).filter(w => w.text); const isCritical = (w) => { // Strukturerad severity är facit när den finns. if (w.severity) return w.severity === 'critical' || w.severity === 'error'; if (w.kind === 'error') return true; // Legacy-heuristik (oförändrad) för sträng-varningar utan severity. const t = w.text || ''; if (/växelriktar|anslutning|mätare|sensor/i.test(t)) return false; return /inställning|orimlig|pausa|kunde inte skapa|schema/i.test(t); }; const errs = items.filter(isCritical); const warns = items.filter(w => !errs.includes(w)); const toSettings = (w) => w.action === 'settings' || (!structured && isCritical(w)); // legacy: kritiska länkade redan till settings return ( <> {errs.map((w, i) => (
{w.text}
{toSettings(w) && Till inställningar}
))} {warns.map((w, i) => toSettings(w) ? (
{w.text}
Till inställningar
) : (
{w.text}
))} ); })()} {/* Dashboard-flikar — en-kolumns lugnare startvy (UX 2026-06-04) */}
{booting ? (
Hämtar din data…
) : ( {dashTab==='oversikt' && ( )} {/* Status cards row — Peak shaving → Ekonomi-fliken */} {dashTab==='ekonomi' && (home?.peakEnabled ?? true) && (
setTweak('c_peak', !t.c_peak)}> setTweak('peakLimit', v)} soc={soc} scenario={t.scenario} livePeak={livePayloads.peak}/>
)} {/* #60 + Beslutsunderlag sammanslaget: "Därför ser dagen ut så här" bär nu BÅDE backendens färdiga förklaring OCH pris-underlaget (tidigare eget chip-kort) — en enda "varför"-yta. Döljs helt om både text och decision_context saknas. */} {dashTab==='oversikt' && ( )} {/* EV-kort bor numera på egna fliken "Bil & laster" (IA 2026-07-08) — inte längre på Översikt. */} {dashTab==='oversikt' && v2 && gravio && window.FlowV2 && ( setTweak('c_gflow', !t.c_gflow)}> )} {dashTab==='oversikt' && v2 && gravio && window.ClockV2 && ( setTweak('c_gclock', !t.c_gclock)}> )} {dashTab==='oversikt' && !v2 && t.showFlow && ( setTweak('c_combo', !t.c_combo)}>
100)} evNames={evNames} evMock={evMockFlow}/> {t.schemaView === 'clock' ? : setScrub(null)} refreshCount={refreshMeta.schedule.count} refreshAt={refreshMeta.schedule.at}/> }
)} {/* Plan-graf (stapel/area-modell) på Översikt under klockan (UX 2026-06-04, A) */} {dashTab==='oversikt' && ( setTweak('c_planchart', !t.c_planchart)}> setTweak(k, v)} refreshCount={refreshMeta.schedule.count} refreshAt={refreshMeta.schedule.at} quarterHistory={livePayloads.quarterHistory}/> )} {dashTab==='oversikt' && ( setTweak('c_glchart', !t.c_glchart)}> setTweak(k, v)} dayView={t.glcDay} onDayView={(v)=>setTweak('glcDay', v)} quarterHistory={livePayloads.quarterHistory} hasTomorrow={slots.length>=192 && slots[96] && (slots[96].buy!==slots[0].buy || slots[100].buy!==slots[4].buy)}/> )} {/* Facit-fliken borttagen (2026-07-08, användarbeslut). */} {dashTab==='prognos' && ( setTweak('c_forecast', !t.c_forecast)}> setTweak(k, v)} refreshCount={refreshMeta.schedule.count} refreshAt={refreshMeta.schedule.at} quarterHistory={livePayloads.quarterHistory}/> )} {/* Bil & laster (IA 2026-07-08) — samlad "se planen + styr planen"-yta: live-status + snabbstyrning (EvCards) + hela laddnings-/last-planen (ForecastCard-band). Konfiguration (hårdvara) bor kvar i Inställningar. */} {dashTab==='laddning' && (() => { const hasDeferrable = Array.isArray(livePayloads.settings?.deferrable_loads) && livePayloads.settings.deferrable_loads.length > 0; const anyPlannable = showEvCard || hasDeferrable || !!evLoad; return ( <>
Bil & laster
Se när bilen och dina flyttbara laster körs — och styr planen.
Konfigurera
{showEvCard && ( )} {/* Planera skjutbara laster direkt här (IA 2026-07-08) */} {v2 && window.EvWeekCards && ( setTweak('c_gweek', !t.c_gweek)}> )} {anyPlannable && ( setTweak('c_ladplan', !t.c_ladplan)}> setTweak(k, v)} refreshCount={refreshMeta.schedule.count} refreshAt={refreshMeta.schedule.at} quarterHistory={livePayloads.quarterHistory}/> )} ); })()} {dashTab==='oversikt' && ( setTweak('c_plan', !t.c_plan)}> )} {dashTab==='ekonomi' && v2 && window.StatsV2 && ( setTweak('c_gstat', !t.c_gstat)}> )} {/* "Besparingar sedan start" (/api/savings/summary) mäter batteriets nettoresultat enligt transaktionsmodellen — inte samma sak som savings_vs_passive_kr. De två får inte stå omärkta bredvid varandra (beslut 2026-08-16), så med nya vyn på flyttas talet in i StatsV2 som en egen "sedan start"-rad. */} {dashTab==='ekonomi' && !v2 && t.showSavings && ( setTweak('c_savings', !t.c_savings)}> )}
)}
{/* Mobile top tab bar (visible only via CSS media query) */}
); } /* Responsiv mount-grind (2026-05-29): - <600px: MobileApp (vertikal stack, swipe-tabs, optimerad för iPhone/Android) - >=600px: App (desktop-rendering — styles.css gör den single-column upp till 1279px, sen 2-kolumn med sidebar vid 1280+). Tesla (1100px) och iPad landscape får alltså desktop-bundlen i 1-kolumn-läge — inte den mobil-anpassade vyn. Iteration 1 (1199px-tröskel) gav krympt desktop-layout; iteration 2 (med 1199px) gav klämd mobile-bundle på Tesla. Denna tredje iteration låter desktop-renderingen själv skala via media queries i styles.css. */ /* Läge "Ny" går ALLTID till , även på telefon: MobileApp är vår gamla mobilstack och känner inte till kopian, så en telefon med ?design=ny skulle annars få gammal design. Hans app är dessutom mobil-först — max-w-2xl är basbredden, lg: bara ett påslag. Läget läses här, före mount, ur samma två källor som i App: ?design vinner, annars tweaken. */ const __gcDesignNy = (() => { const q = new URLSearchParams(location.search).get('design'); if (q === 'ny') return true; if (q === 'gammal') return false; try { const s = localStorage.getItem('hems.design'); if (s === 'ny') return true; if (s === 'gammal') return false; } catch(e){} // Tweakvärden bor i filens EDITMODE-block (värden host:en skriver tillbaka), // inte i localStorage — utgångsläget utan sparat val är alltså defaulten. return !!(typeof TWEAK_DEFAULTS !== 'undefined' && TWEAK_DEFAULTS.gravioV2); })(); ReactDOM.createRoot(document.getElementById('root')).render( window.matchMedia('(max-width: 599px)').matches && window.MobileApp && !__gcDesignNy ? : );