/* mobile.jsx — mobile rendering that REUSES the desktop components. Same data layer (window.hems + window.hemsAdapter) AND same visual components (window.EnergyFlow / ElprisSchemaClock / ForecastCard / BatteryCard / PeakShavingCard / SavingsCard / PlanStrip). Mobile only changes the layout to one column + tabs + swipe. No parallel simplified visualisations. Mounts when matchMedia('(max-width: 599px)') matches at load (riktig mobil). 600-1279px renderas av desktop-App men styles.css gör den single-column. */ const M_BASE_URL = window.HEMS_BASE(); // Säkerhetsfix 2026-05-29: STRIKT sess_*-validering. Accepterar ENDAST // riktiga session-tokens från login (sess_*). Gamla hårdkodade hems_*-nycklar // från tidigare cutover-fönster rensas → tvingar fram login. Detta hindrar // att en spöktoken i localStorage (från före säkerhetsfixen) ger åtkomst till // en annan användares data. function getApiKey(){ if (typeof localStorage === 'undefined') return ''; let tok = localStorage.getItem('hems_session_token') || ''; if (tok && !tok.startsWith('sess_')) { localStorage.removeItem('hems_session_token'); tok = ''; } return tok; } function MTopBar({ name, connected, nerd, onNerd }){ return (
Gravio
{name || 'Gravio'}
{connected ? 'Live · ansluten' : 'Mockdata'}
{ try { localStorage.setItem('hems.design','ny'); } catch(e){} }}>Ny
); } const M_TABS = [ { id: 'overview', label: 'Översikt' }, { id: 'schedule', label: 'Schema' }, { id: 'ev', label: 'Bil' }, { id: 'stats', label: 'Statistik' }, { id: 'more', label: 'Mer' }, ]; function MTabs({ tab, setTab }){ return ( ); } // ── Main mobile app ────────────────────────────────────────────────── function MobileApp(){ const [tab, setTab] = React.useState('overview'); const [slots, setSlots] = React.useState(null); const [live, setLive] = React.useState(null); const [savings, setSavings] = React.useState(null); const [peak, setPeak] = React.useState(null); const [quarterHistory, setQuarterHistory] = React.useState(null); const [evLoad, setEvLoad] = React.useState(null); const [evCfg, setEvCfg] = React.useState(null); const [evSession, setEvSession] = React.useState(null); const [settingsObj, setSettings] = React.useState(null); const [tomorrowPrelim, setTomorrowPrelim] = React.useState(false); // Omhämtning direkt efter sparning i EV-kortet — samma skäl som desktop: // annars visas den gamla laddarkonfigurationen till nästa poll (2026-08-08). const refreshSettingsNow = React.useCallback(async () => { try { const s = await window.hems.getSettings(); setSettings(s || null); setEvCfg(s?.ev_charging || null); setEvSession(s?.ev_session || null); } catch (e) { console.error('settings refresh failed', e); } }, []); const [home, setHome] = React.useState(null); const [dataStatus, setDataStatus] = React.useState(null); const [planWhy, setPlanWhy] = React.useState(''); const [now, setNow] = React.useState(window.hemsDate()); const [peakLimit, setPeakLimit] = React.useState(5); // Standardläge vs nördläge (Lennie 2026-06-18): översikten visar bara det // absolut nödvändigaste STORT. Nördläge bygger på SAMMA bas men lägger till // all detaljdata (flow, klocka, prognos, per-batteri, avvikelser). Persistas. const [nerd, setNerd] = React.useState(() => { try { return localStorage.getItem('hems_nerd_mode') === '1'; } catch(e){ return false; } }); const toggleNerd = () => setNerd(v => { const nv = !v; try { localStorage.setItem('hems_nerd_mode', nv ? '1' : '0'); } catch(e){} return nv; }); // Hero-kortet: tryck för att fälla ut "specifik information" (rek effekt etc.) const [heroOpen, setHeroOpen] = React.useState(false); // Forecast series visibility (same keys as desktop) const [fc, setFc] = React.useState({ solar:true, cons:true, soc:true, buy:true, sell:false, solBands:false, consBands:false, }); const toggleFc = (k, v) => setFc(s => ({ ...s, [k==='fc_solar'?'solar':k==='fc_cons'?'cons':k==='fc_soc'?'soc' :k==='fc_buy'?'buy':k==='fc_sell'?'sell' :k==='fc_solBands'?'solBands':'consBands']: v, })); React.useEffect(()=>{ const i=setInterval(()=>setNow(window.hemsDate()),60000); return ()=>clearInterval(i); },[]); React.useEffect(() => { let cancelled = false; let stopFns = []; (async () => { if (!window.hemsAdapter) return; const apiKey = getApiKey(); if (!apiKey) { window.location.href = 'login.html'; return; } await window.hemsAdapter.bootHems({ baseUrl: M_BASE_URL, apiKey, useMock: false, sampleUrl: 'hems-sample.json', }); 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; setSlots(window.hemsAdapter.buildSlots(sched, hist, settings)); // EV-/last-band + rader (SPEC_LASTER_EV §3): samma buildEvLoadPlan // som desktop — sched ÄR /ml/analysis-payloaden, settings färska. setEvLoad(window.buildEvLoadPlan ? window.buildEvLoadPlan(sched, settings) : null); // EV-kortet (snabbjustering av session) behöver ev_charging-konfig setEvCfg(settings?.ev_charging || null); setEvSession(settings?.ev_session || null); setSettings(settings || null); // SPEC_IMORGON: morgondagen preliminär tills priserna släppts. setTomorrowPrelim(sched?.tomorrow_prices_available === false); // #60: färdig "därför"-mening från backend (kan saknas → dölj) setPlanWhy(typeof sched?.plan_explanation === 'string' ? sched.plan_explanation : ''); const h = window.hemsAdapter.buildHome(settings); setHome(h); if (h?.peakLimitKw) setPeakLimit(h.peakLimitKw); } catch(e){ console.warn('schedule', e); } }; const refreshLive = async () => { try { const d = await window.hems.getLive(); if(!cancelled) setLive(d); } catch(e){} }; const refreshSavings = async () => { try { const d = await window.hems.getSavings(); if(!cancelled) setSavings(d); } catch(e){} }; const refreshPeak = async () => { try { const d = await window.hems.getPeak(); if(!cancelled) setPeak(d); } catch(e){} }; const refreshQH = async () => { try { const d = await window.hems.getQuarterHistory(); if(!cancelled) setQuarterHistory(d); } catch(e){} }; const refreshDS = async () => { try { const d = await window.hems.getDataStatus(); if(!cancelled) setDataStatus(d); } catch(e){} }; await Promise.all([refreshSchedule(), refreshLive(), refreshSavings(), refreshPeak(), refreshQH(), refreshDS()]); stopFns.push(window.hems.poll('live', refreshLive)); stopFns.push(window.hems.poll('schedule', refreshSchedule)); stopFns.push(window.hems.poll('savings', refreshSavings)); stopFns.push(window.hems.poll('peak', refreshPeak)); stopFns.push(window.hems.poll('quarterHistory', refreshQH)); stopFns.push(window.hems.poll('dataStatus', refreshDS)); })(); return () => { cancelled = true; stopFns.forEach(fn=>{ try{fn();}catch(e){} }); }; }, []); // ── Swipe between tabs (on root, not on chart SVGs) ────────────────── const touch = React.useRef(null); const onTouchStart = (e) => { // Ignore gestures that start inside a horizontally-scrollable chart if (e.target.closest('.schema-wrap, .forecast-wrap, .clock-svg, .flow-svg, .schema-svg, .forecast-svg')) { touch.current = null; return; } const t = e.touches[0]; touch.current = { x: t.clientX, y: t.clientY, t: Date.now() }; }; const onTouchEnd = (e) => { if (!touch.current) return; const t = e.changedTouches[0]; const dx = t.clientX - touch.current.x; const dy = t.clientY - touch.current.y; touch.current = null; if (Math.abs(dx) > 50 && Math.abs(dy) < 40){ const idx = M_TABS.findIndex(x => x.id === tab); if (dx < 0 && idx < M_TABS.length - 1) setTab(M_TABS[idx+1].id); if (dx > 0 && idx > 0) setTab(M_TABS[idx-1].id); } }; if (!slots){ return (
Laddar…
); } const nowQ = Math.min(95, now.getHours()*4 + Math.floor(now.getMinutes()/15)); let cur = slots[nowQ]; if (live && window.hemsAdapter) cur = window.hemsAdapter.buildLiveCur(live, slots, now); const soc = cur.soc ?? 50; const meta = (window.ACTION_META || {})[cur.action] || { label: cur.action, color: '#888' }; // Dynamic explanation: prefer the live decision's own `reason` (carries real // numbers from backend), fall back to the static template text. // Pipeline-trace ("LP: X | PowerMatch: Y | hysteres: Z") från // quarterly_decisions filtreras bort — visa bara backend-reason från // live (clean) eller ACTION_META.desc. const _curReason = window.gcReason(cur.reason, ''); const reasonText = (live && live.reason) || _curReason || meta.desc; const laddbox = live ? (live.ev_power != null) : false; const evCharging = live ? (live.ev_power > 100) : false; const secondBattery = !!cur.batt2; // Next planned action description for BatteryCard const ACTION_META = window.ACTION_META || {}; const planNext = (() => { for (let i=nowQ+1; i<96; i++){ if (slots[i].action !== cur.action){ const h = Math.floor(i/4), m = (i%4)*15; return `${(ACTION_META[slots[i].action]||{}).label || ''} kl ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; } } return '— inga ändringar idag'; })(); const peakEnabled = home?.peakEnabled ?? true; return (
{/* Systemvarningar (#282) — warnings_structured om fältet finns, annars warnings[] */} {(() => { const structured = Array.isArray(dataStatus?.warnings_structured) ? dataStatus.warnings_structured : null; const ws = structured || dataStatus?.warnings; if (!Array.isArray(ws) || ws.length === 0) return null; return ws.map((raw, i) => { const w = typeof raw === 'string' ? { text: raw } : { text: raw.text || raw.message || '', severity: raw.severity, action: raw.action, kind: raw.kind }; if (!w.text) return null; const isErr = w.severity ? (w.severity === 'critical' || w.severity === 'error') : w.kind === 'error' ? true : (/växelriktar|anslutning|mätare|sensor/i.test(w.text) ? false : /inställning|orimlig|pausa|kunde inte skapa|schema/i.test(w.text)); const toSettings = w.action === 'settings' || (!structured && isErr); return (
{w.text}
{toSettings && Till inställningar}
); }); })()} {/* ── ÖVERSIKT ── */} {tab === 'overview' && (<>
HEMS just nu
{meta.label}
{reasonText &&
{reasonText}
} {(heroOpen || nerd) && (() => { const pb = Array.isArray(live?.per_battery_actions) ? live.per_battery_actions : null; const disch = new Set(['selfconsumption','export','peakshaving','discharge']); const fmtKw = (b) => { const mag = Math.abs(b?.power_kw ?? 0); const kw = disch.has(b?.action) ? -mag : mag; const sign = kw > 0.005 ? '+' : kw < -0.005 ? '−' : ''; const col = kw > 0.005 ? 'var(--c-sell)' : kw < -0.005 ? 'var(--c-cover)' : 'var(--ink-2)'; return { txt:`${sign}${mag.toFixed(2).replace('.',',')} kW`, col }; }; // Enkel-batteri (eller ingen per-batteri-data): visa en rad rek effekt if (!pb || pb.length < 2){ const single = (pb && pb.length === 1) ? pb[0] : null; const recKw = single ? (() => { const m=Math.abs(single.power_kw??0); return disch.has(single.action)?-m:m; })() : (live?.power_kw != null ? live.power_kw : (live ? (live.battery_power||0)/1000 : null)); if (recKw == null) return null; const mag = Math.abs(recKw); const sign = recKw > 0.005 ? '+' : recKw < -0.005 ? '−' : ''; const col = recKw > 0.005 ? 'var(--c-sell)' : recKw < -0.005 ? 'var(--c-cover)' : 'var(--ink-2)'; return (
Rekommenderad effekt {sign}{mag.toFixed(2).replace('.',',')} kW
); } return (
Rekommenderad effekt
{pb.map((b, i) => { const label = b?.battery ? `Batteri ${b.battery}` : `Batteri ${i+1}`; if (b?.ai_controlled) return (
{label}AI-styrt
); const f = fmtKw(b); const act = b?.action && window.ACTION_META?.[window.hemsAdapter?.mapAction?.(b.action) || b.action]; return (
{label}{act ? ` · ${act.label}` : ''} {f.txt}
); })}
); })()}
{/* Energiflöde — alltid synligt (Lennie 2026-06-18): live-bilden av vad som händer just nu, även med Avancerat avslaget. Visar sol, förbrukning och batteri — därför behövs inga separata brickor för dem. */} window.evModel.displayName(c,i)) : undefined}/> {/* Spotpris — det enda nyckeltalet flödet inte visar, stort och tydligt */}
Spotpris just nu
{Math.round(cur.buy)}öre/kWh
{/* Vad händer härnäst */}
Härnäst
{planNext.replace(/^—\s*/,'')}
{/* EV-kort bor numera på egna tabben "Bil" (IA 2026-07-08). */} {/* ── AVANCERAT: all detaljdata, samma bas ── */} {nerd && (<> {window.GravioLiveChart && ( )} )} )} {/* ── SCHEMA ── */} {tab === 'schedule' && (<> )} {/* ── BIL & LASTER (IA 2026-07-08) — se planen + styr planen ── */} {tab === 'ev' && (() => { const chargers = window.evModel ? window.evModel.normalize(settingsObj) : []; const hasDeferrable = Array.isArray(settingsObj?.deferrable_loads) && settingsObj.deferrable_loads.length > 0; const anyPlannable = chargers.length > 0 || hasDeferrable || !!evLoad; return (<>
Bil & laster
Se när bilen och dina laster körs — och styr planen.
Konfigurera
{chargers.length > 0 && window.EvCards && ( )} {/* Planera skjutbara laster direkt här (IA 2026-07-08) */} {window.LoadsPlanner && } {anyPlannable && ( )} ); })()} {/* ── STATISTIK ── */} {tab === 'stats' && (<> {peakEnabled && ( )} )} {/* ── MER ── */} {tab === 'more' && ( <>
Inställningar
Batteri, sol, priser, peak shaving
Hjälp
Guide, FAQ och felsökning
{home && (

Ditt hem

Område{home.area}
Batteri {home.batteryCapacityKwh?.toFixed(1).replace('.',',')} kWh
Solpaneler {home.solarKwp?.toFixed(1).replace('.',',')} kWp
)} )}
); } window.MobileApp = MobileApp;