/* 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 (
);
}
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 (
{M_TABS.map(t => (
setTab(t.id)}>{t.label}
))}
);
}
// ── 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 (
);
}
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 (
);
});
})()}
{/* ── ÖVERSIKT ── */}
{tab === 'overview' && (<>
HEMS just nu
{meta.label}
{reasonText &&
{reasonText}
}
setHeroOpen(o=>!o)} aria-expanded={heroOpen}>
{heroOpen ? 'Dölj detaljer' : 'Visa detaljer'}
{(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
)}
{ localStorage.removeItem('hems_session_token'); window.location.href = 'login.html'; }}>
Logga ut
Avsluta sessionen
>
)}
);
}
window.MobileApp = MobileApp;