/* app-shell.jsx — dashboardens datalager och montering. Hämtar live/schema/historik/settings, bygger Gravio-dagen och renderar GcApp. (Hette app.jsx och bar även den gamla dashboarden; den vyn och växeln ?design=ny|gammal togs bort 15/9, rev 4.) */ 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(); const SHELL_ACCENTS = { blue: { blue:'#5BA8FF', violet:'#9D7BFF' }, green: { blue:'#3FCC8B', violet:'#5BA8FF' }, amber: { blue:'#F4C84B', violet:'#FF8A47' }, }; /* Laddvy medan /me hämtas (A1). Ingen data, inga anrop — bara märket. */function BootSplash({ text = 'Laddar …' }){ return (
{text}
); } /* ---------- 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)} /> setTweak('showFlow', v)} /> setTweak('showSavings', v)} /> setTweak('accent', v)} /> ); /* Inbäddad admin-vy utan egen session (demoläge i admin.html): visa exempeldata i stället för att skicka iframen till login. Med session gäller live + as_customer. */ const EMBED_NOAUTH = !!window.HEMS_EMBED && !(() => { try { return /^sess_/.test(localStorage.getItem('hems_session_token') || ''); } catch (e) { return false; } })(); /* ---------- App ---------- */ function App(){ const [t0, setTweak] = useTweaks(TWEAK_DEFAULTS); const t = EMBED_NOAUTH ? { ...t0, useLive:false } : t0; // 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). /* v674 (André 27/9: allt sådant ska uppdatera sig automatiskt, oavsett om laddning blir planerad eller inte): efter en sparning hämtas även planen, direkt och sedan efter 30, 90 och 180 s, så backendens omplanering syns utan omladdning. Samma sak efter manuell körning (window.hemsPlanChanged anropas från lastfonster.js). */ const schedRef = React.useRef(null), bumpRef = React.useRef([]); const planBump = React.useCallback(() => { bumpRef.current.forEach(clearTimeout); bumpRef.current = [0, 30000, 90000, 180000].map((ms) => setTimeout(() => { if (schedRef.current) schedRef.current(); }, ms)); }, []); React.useEffect(() => { window.hemsPlanChanged = planBump; return () => { bumpRef.current.forEach(clearTimeout); if (window.hemsPlanChanged === planBump) window.hemsPlanChanged = null; }; }, [planBump]); /* v675 (André 27/9): planstämpeln i livevärdena. /api/live-data.plan_at = när planen senast räknades om. Ändras den hämtas planen direkt, så en ny plan syns inom 10 s utan tätare frågor. Saknas fältet händer ingenting. */ const planAtRef = React.useRef(null); const planAt = livePayloads.live && livePayloads.live.plan_at != null ? String(livePayloads.live.plan_at) : null; React.useEffect(() => { if (planAt == null) return; const prev = planAtRef.current; planAtRef.current = planAt; if (prev != null && prev !== planAt && schedRef.current) schedRef.current(); }, [planAt]); const refreshSettingsNow = React.useCallback(async () => { try { const settings = await window.hems.getSettings(); if (window.uiPrefs) window.uiPrefs.hydrate(settings); setLivePayloads(p => ({ ...p, settings })); } catch (e) { console.error('settings refresh failed', e); } planBump(); }, [planBump]); // 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; /* Vypreferenser (zoom, färger, serieval) följer användaren så snart settings bär ui_prefs; saknas fältet är det en tom operation. */ if (window.uiPrefs) window.uiPrefs.hydrate(settings); 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); } }; schedRef.current = refreshSchedule; 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]); /* Accenttweaken (låg bordet i gamla vyns konstantblock, borttaget rev 4). */ React.useEffect(()=>{ const a = SHELL_ACCENTS[t.accent]; if (!a) return; 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 [ecoBusy, setEcoBusy] = React.useState(false); 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 () => { setEcoBusy(true); try { const data = await window.hems.getEconomicsComparison(ecoRange.from, ecoRange.to, ecoRange.includeToday); if (!stop) setEcoCmp(data); } catch (e) { if (e && e.noKeyYet) { setEcoBusy(false); return; } // körs om när nyckeln finns console.warn('economics/comparison failade', e); if (!stop) setEcoCmp({ days: [], totals: null }); } if (!stop) setEcoBusy(false); }; 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 (!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; }, [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]); /* ===== Dashboarden ===================================================== Hans header, hans flikrad, hans enkolumnslayout. Datalagret ovanför är gemensamt — GcApp hämtar ingenting själv. Tweak-panelen följer med. (Rev 4, 15/9: den gamla vyn och växeln ?design=ny|gammal är borta — det finns bara en dashboard, och den bär planspärren.) */ if (!window.GcApp) return ; const liveText = !t.useLive ? 'Demoläge · mockdata' : hasLiveData ? 'Live · ansluten' : 'Live · ansluter…'; /* Bootgejt: ingen data alls förrän riktiga siffror finns — bara en lugn laddyta. hasLiveData blir sann när FÖRSTA svaret (ofta live-payloaden) landat, men slots-fallbacken är mock tills SCHEMAT (liveSlots) kommit, så gejten står på liveSlots specifikt: det är schemat som föder day/tomorrow/graferna. Fel-läget 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 ( ); } /* A1 (rev 4, backend 15/9): INGET monteras och inget dataanrop görs förrän /me svarat. Utan grinden startade App:s pollning direkt, och en kund med utgången prenumeration hann få ett dussin 402 innan utgången-sidan tog över (staging-loggen 15/9 20:00). Faller /me, eller saknas entitlements- fältet, gäller som förut: allt upplåst — HemsEnt.load() resolvar ändå. */ const __hemsRoot = ReactDOM.createRoot(document.getElementById('root')); __hemsRoot.render(); /* Grinden (villkor K3 + trappan T2, 20/9): EntGate visar godkännandesidan eller spärrsidan i stället för App — App monteras inte, så ingen pollning och inget dataanrop. Byter vy även mitt i sessionen (402/403). */ const __mount = () => __hemsRoot.render(window.EntGate ? : ); if (window.HemsEnt) window.HemsEnt.load().then(__mount, __mount); else __mount();