/* 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 (
);
};
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 (
Översikt
Inställningar
Hjälp
Aktivt hem
{name}Område {area}
{battKwh.toFixed(1).replace('.',',')} kWh· {solarKwp.toFixed(1).replace('.',',')} kWp
);
};
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) => (
))}
{warns.map((w, i) => toSettings(w) ? (
{w.text}
Till inställningar
) : (
))}
>
);
})()}
{/* Dashboard-flikar — en-kolumns lugnare startvy (UX 2026-06-04) */}
setDashTab('oversikt')}>Översikt
setDashTab('prognos')}>Prognos
setDashTab('laddning')}>Bil & laster
setDashTab('ekonomi')}>Ekonomi
{booting ? (
) : (
{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) */}
Översikt
Inställningar
Hjälp
);
}
/* 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
?
:
);