/* ev-loads.jsx — Elbil & skjutbara laster på dashboarden (SPEC_LASTER_EV_SETTINGS_DASHBOARD, uppdaterad 2026-06-11 em — §3 FRYST) 1. EvCard — EV-kort på Översikt (§2): status, SOC-progress, snabbjustering (PATCH /api/settings {ev_session} via window.hems.patchEvSession) samt unmet-varning när målet inte ryms före avresetid (§3a). 2. buildEvLoadPlan — läser de FRYSTA toppnivåfälten ur /ml/analysis: ev_plan {intervals:[{start_quarter, end_quarter(EXKL), kwh_per_quarter}], total_kwh, unmet_kwh} deferrable_plan [{load_id, name, power_kw, start_quarter, end_quarter(EXKL), day, cost_kr}] ev_unmet_kwh (samma värde som ev_plan.unmet_kwh) Kvartindex är globala 0–191; null/saknat fält → banden döljs helt. Internt använder vi INKLUSIVT i1 (= end_quarter − 1). 3. demoEvLoadPlan — syntetiserar en analysis-fragment i EXAKT samma fältform och kör den genom buildEvLoadPlan (formparitet garanterad). Används bara när riktiga fält saknas och tweaks är på. */ // ---- Facit-helpers (SPEC_EV_ACTUAL_HISTORIK 2026-07-29) ----------------- // Brusgolv: under detta räknas kvarten som ingen laddning (spec §2). const EV_ACT_FLOOR = 0.02; function _evDayAfter(ymd){ const d = new Date(`${ymd}T12:00:00`); if (isNaN(d.getTime())) return null; d.setDate(d.getDate() + 1); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } // Minuter sedan midnatt i ANLÄGGNINGENS tid (7p) — aldrig klientklockan. function _evLocalMinutesNow(){ try { const n = (typeof window.hemsDate === 'function') ? window.hemsDate() : new Date(); return n.getHours()*60 + n.getMinutes(); } catch(e){ return null; } } // ev_actual → { byQ, perCharger } i GLOBALA horisontkvartar 0..191. // per_charger-nycklarna är dag-lokala "q0".."q95" ankrade i .date; planen är // global. Fel dag (gårdagen) ignoreras helt — v1 gäller innevarande dag. function _evActualByQuarter(a){ if (!a || typeof a !== 'object') return null; const pc = a.per_charger; if (!pc || typeof pc !== 'object') return null; const today = (typeof window.hemsToday === 'function') ? window.hemsToday() : null; let base = null; if (!a.date || !today || a.date === today) base = 0; else if (a.date === _evDayAfter(today)) base = 96; if (base == null) return null; const byQ = {}, perCharger = {}; Object.keys(pc).forEach(cid => { const qmap = pc[cid]; if (!qmap || typeof qmap !== 'object') return; const out = perCharger[cid] = {}; Object.keys(qmap).forEach(k => { const m = /^q(\d{1,2})$/.exec(k); if (!m) return; const q = Number(m[1]); if (!(q >= 0 && q <= 95)) return; const kwh = Number(qmap[k]); if (!isFinite(kwh) || kwh <= 0) return; const gi = base + q; byQ[gi] = (byQ[gi] || 0) + kwh; out[gi] = (out[gi] || 0) + kwh; }); }); return Object.keys(byQ).length ? { byQ, perCharger, date: a.date || null } : null; } // ---- Plan-band ur /ml/analysis (FRYSTA fält, §3a/3b) ------------------- function buildEvLoadPlan(analysis, settings){ if (!analysis) return null; // §3a — elbilens laddfönster. kW = kwh_per_quarter × 4. // Settings är sanningskälla när de finns: är smart billaddning AVSTÄNGD // döljer vi EV-bandet även om /ml/analysis (5 min TTL + planeringskörning) // fortfarande bär en gammal ev_plan — annars ligger bandet kvar i upp till // ~20 min efter att användaren stängt av (Lennie-klagomål 2026-06-11). // Migrerade users (2026-07-29): ev_chargers[] är sanningskällan när den finns — // legacy ev_charging.enabled ligger kvar false hos migrerade konton och dolde // EV-bandet felaktigt (Magnus). Per-box på/av styrs av boxens egna fält. const _hasChargers = Array.isArray(settings?.ev_chargers) && settings.ev_chargers.length > 0; /* Brytaren per laddbox (ev_chargers[].active, live sedan 2026-08-16 rev 01380): saknat eller true = aktiv, false = boxen planeras inte och får stop. Den lästes BARA av gc-bil.jsx — Plan 24h och Prognos kände inte till den, så bilen låg kvar i planen efter att användaren stängt av laddboxen (rapporterat 3/9). Är ALLA boxar avstängda finns ingen laddning att rita: ev_plan är hushållets aggregat och går inte att dela per box. */ const _allOff = _hasChargers && settings.ev_chargers.every(c => c && c.active === false); const evDisabled = _allOff || (!_hasChargers && !!settings?.ev_charging && settings.ev_charging.enabled === false); // Prioritet (rättelse 2026-07-24): TOPPNIVÅ ev_plan först NÄR NYCKELN FINNS — // den är alltid färsk eller raderad, aldrig stale. Nyckel närvarande men null = // "ingen plan" (INTE fallback). schedule_data.ev_plan används bara när toppnivå- // nyckeln SAKNAS helt. Verifierat mot båda felmoderna (toppnivå borta → fallback; // schedule_data stale → toppnivå). const _sd = analysis.schedule_data || {}; const evPlan = evDisabled ? null : (('ev_plan' in analysis) ? analysis.ev_plan : _sd.ev_plan); // LP:n varierar effekten per kvart → många små, ofta ANGRÄNSANDE intervall // (end_quarter === nästa start_quarter). Rita sådana som ETT visuellt band: // merga konsekutiva intervall men BEHÅLL delarna (parts) — banden // intensitetsgraderas efter kwh_per_quarter och hover-readouten visar // kvartens VERKLIGA effekt (kwh_per_quarter × 4). Ingen snitteffekt på // etiketten — den motsvarar ingen verklig laddeffekt (backend 2026-06-11). const rawEv = (evPlan && Array.isArray(evPlan.intervals) ? evPlan.intervals : []) .filter(iv => iv && iv.start_quarter != null && iv.end_quarter != null && iv.end_quarter > iv.start_quarter) .map(iv => ({ i0: iv.start_quarter, i1: iv.end_quarter - 1, // end_quarter är EXKLUSIV kwhPerQ: iv.kwh_per_quarter || 0, })) .sort((a, b) => a.i0 - b.i0); // ---- Facit per kvart (SPEC_EV_ACTUAL_HISTORIK) ------------------------ // Passerade kvartar ska visa UTFALL, inte den omplanerade intentionen. // Saknas ev_actual ⇒ actKwh null överallt ⇒ exakt samma rendering som före. const actual = _evActualByQuarter(analysis.ev_actual); // En kvart får dömas som MISS först när dess sluttid är > 6 min gammal // (backend flushar var 5:e min). Före dess: rita planen, påstå ingenting. const _mn = _evLocalMinutesNow(); const settledThrough = _mn == null ? -1 : Math.floor((_mn - 6) / 15) - 1; // Plan per kvart (överlappande intervall summeras, som forecast gör). const qPlan = new Map(); rawEv.forEach(p => { for (let i = p.i0; i <= p.i1; i++) qPlan.set(i, (qPlan.get(i) || 0) + p.kwhPerQ); }); const qKeys = new Set(qPlan.keys()); if (actual) Object.keys(actual.byQ).forEach(k => { if (actual.byQ[k] >= EV_ACT_FLOOR) qKeys.add(Number(k)); }); // Per-kvart-celler → run-mergade delar. Utan facit slås angränsande kvartar // med samma kwh ihop igen = identisk bandform som före specen. const parts = []; Array.from(qKeys).sort((a, b) => a - b).forEach(i => { const plan = qPlan.get(i) || 0; let act = null, cls = 'plan'; if (actual){ const raw = actual.byQ[i]; act = raw != null ? raw : (i <= settledThrough ? 0 : null); if (act != null && act < EV_ACT_FLOOR) act = 0; if (act != null){ if (plan > 0 && act > 0) cls = act < plan - EV_ACT_FLOOR ? 'low' : 'ok'; else if (plan > 0) cls = 'miss'; else if (act > 0) cls = 'extra'; else act = null; } } const prev = parts[parts.length - 1]; if (prev && prev.i1 === i - 1 && prev.kwhPerQ === plan && prev.actKwh === act && prev.cls === cls){ prev.i1 = i; return; } parts.push({ i0: i, i1: i, kwhPerQ: plan, actKwh: act, cls }); }); const ev = []; parts.forEach(p => { const prev = ev[ev.length - 1]; if (prev && p.i0 <= prev.i1 + 1){ prev.i1 = Math.max(prev.i1, p.i1); prev.parts.push(p); } else ev.push({ i0: p.i0, i1: p.i1, parts: [p] }); }); const evUnmetKwh = evPlan?.unmet_kwh ?? analysis.ev_unmet_kwh ?? _sd.ev_unmet_kwh ?? 0; const evTotalKwh = evPlan?.total_kwh ?? null; // Dagsfacit för raden under grafen. missFrom/missTo = spannet där planerad // laddning uteblev (enda platsen frontend PÅSTÅR en miss). let evActual = null; if (actual){ let kwh = 0, missedKwh = 0, extraKwh = 0; // Missar samlas till SAMMANHÄNGANDE spann — två separata gap får aldrig // slås ihop till ett påstått spann över kvartar där laddning faktiskt // skedde (husregeln i 7o). const missRanges = []; parts.forEach(p => { const n = p.i1 - p.i0 + 1; if (p.actKwh) kwh += p.actKwh * n; if (p.cls === 'extra') extraKwh += (p.actKwh || 0) * n; if (p.cls === 'miss'){ const mk = p.kwhPerQ * n; missedKwh += mk; const last = missRanges[missRanges.length - 1]; if (last && last.i1 === p.i0 - 1){ last.i1 = p.i1; last.kwh += mk; } else missRanges.push({ i0: p.i0, i1: p.i1, kwh: mk }); } }); evActual = { kwh, missedKwh, extraKwh, missRanges, perCharger: actual.perCharger, date: actual.date }; } // ---- Sessioner (2026-08-01) ------------------------------------------- // En AVSLUTAD nattsession och en NY dagsplan är två skilda saker. Rad och // etikett fick aldrig spänna över gränsen: 01:45–05 (facit) + 10–16 (plan) // slogs ihop till "laddar 01:45–16:00 · 42,2 kWh" = ser ut som EN 14-timmars // laddning. Ny session vid ≥2 h uppehåll, eller när NU ligger i uppehållet. const EV_SESSION_GAP_Q = 8; const nowQ = _mn == null ? null : Math.floor(_mn / 15); const evSessions = []; ev.forEach(g => { const prev = evSessions[evSessions.length - 1]; const gap = prev ? g.i0 - prev.i1 - 1 : Infinity; const nowBetween = !!prev && nowQ != null && prev.i1 < nowQ && g.i0 > nowQ; if (prev && gap < EV_SESSION_GAP_Q && !nowBetween){ prev.i1 = Math.max(prev.i1, g.i1); prev.groups.push(g); } else evSessions.push({ i0: g.i0, i1: g.i1, groups: [g] }); }); evSessions.forEach(s => { let planKwh = 0, actKwh = 0, missedKwh = 0, hasAct = false; const missRanges = []; s.groups.forEach(g => g.parts.forEach(p => { const n = p.i1 - p.i0 + 1; planKwh += (p.kwhPerQ || 0) * n; if (p.actKwh != null){ hasAct = true; actKwh += p.actKwh * n; } if (p.cls === 'miss'){ const mk = p.kwhPerQ * n; missedKwh += mk; const last = missRanges[missRanges.length - 1]; if (last && last.i1 === p.i0 - 1){ last.i1 = p.i1; last.kwh += mk; } else missRanges.push({ i0: p.i0, i1: p.i1, kwh: mk }); } })); s.planKwh = planKwh; s.actKwh = hasAct ? actKwh : null; s.missedKwh = missedKwh; s.missRanges = missRanges; // Avslutad = hela spannet är settled OCH facit finns ⇒ visas som facit. s.done = settledThrough >= s.i1 && hasAct; s.active = nowQ != null && nowQ >= s.i0 && nowQ <= s.i1; }); // §3a väntläge (backend-beställning 2026-06-11 natt): användaren har angett // en laddsession (ev_session i GET /api/settings) men ev_plan saknas/null — // planeraren har inte hunnit. Visa "inte planerad än" SÅ LÄNGE avresetiden // inte passerats; därefter är sessionen förbrukad och inget visas. // Nästa avreseförekomst räknas från set_at (HH:MM ≤ set_at ⇒ dagen efter). let evPending = false; const sess = settings?.ev_session; if (!evDisabled && !ev.length && sess && sess.departure_time){ const m = /^(\d{1,2}):(\d{2})$/.exec(sess.departure_time); if (m){ const base = sess.set_at ? new Date(sess.set_at) : new Date(); if (!isNaN(base.getTime())){ const dep = new Date(base); dep.setHours(+m[1], +m[2], 0, 0); if (dep <= base) dep.setDate(dep.getDate() + 1); evPending = new Date() < dep; } } } // §3b — skjutbara laster. En post per last och dygn; wrap över midnatt // förekommer (start 92, end 100) — globala index hanterar det naturligt. // Samma toppnivå-först-regel som ev_plan: toppnivå deferrable_plan när nyckeln // finns, annars schedule_data. const _defRaw = ('deferrable_plan' in analysis) ? analysis.deferrable_plan : _sd.deferrable_plan; const planArr = Array.isArray(_defRaw) ? _defRaw : []; // SPEC_VARFOR_INTE_PLANERAD §3.1: `unplaced` [{load_id, name, reason, detail}] // — orsaken till att en last INTE placerades. Samma toppnivå-först-regel // (annars fastnar orsaken i L2-cachen som EV-bandet gjorde 29/7). // Fältet är ÄNNU INTE deployat: saknas det beter allt sig som i dag. const _unpRaw = ('unplaced' in analysis) ? analysis.unplaced : _sd.unplaced; const unplacedArr = Array.isArray(_unpRaw) ? _unpRaw : []; const cfgLoads = (Array.isArray(settings?.deferrable_loads) ? settings.deferrable_loads : []) .filter(l => l && l.enabled !== false); // Last-rader: en per AKTIV last ur settings; posterna (band) joinas på // load_id (fallback namn). Last utan post → "ryms inte i fönstret". const usedKeys = new Set(); const loads = cfgLoads.map((l, idx) => { const posts = planArr.filter(p => (p.load_id != null && p.load_id === l.id) || (l.name && p.name === l.name)); posts.forEach(p => usedKeys.add(p)); // Skilj "inte planerad än" från "ryms inte": INGEN post alls = planeraren // har inte hunnit (ny last; 5 min cache + planeringskörning ~15 min) → // neutral väntstatus. "Ryms inte" påstås BARA vid explicit signal: en post // utan giltigt fönster (start_quarter null) eller fits === false / // unmet === true (föreslaget till backend, se DESIGN_STATE 7g). const unfit = posts.length > 0 && posts.every(p => p.fits === false || p.unmet === true || p.start_quarter == null || p.end_quarter == null); // §4.1 tredje tillståndet: finns en ORSAK är det inte "väntar" utan // "kan inte köras". Orsaken vinner över båda de gamla lägena. const up = unplacedArr.find(u => u && ((u.load_id != null && u.load_id === l.id) || (l.name && u.name === l.name))) || null; return { key: l.id ?? `cfg-${idx}`, id: l.id, name: l.name || 'Last', power_kw: l.power_kw, earliest: l.earliest, latest: l.latest, solar_only: l.solar_only === true, // SPEC_SOLAR_ONLY_LOADS — sol-badge + reason unfit, reason: up && up.reason ? String(up.reason) : null, detail: (up && up.detail && typeof up.detail === 'object') ? up.detail : null, max_parts: l.max_parts, bands: posts .filter(p => p.start_quarter != null && p.end_quarter != null && p.end_quarter > p.start_quarter) .map(p => ({ i0: p.start_quarter, i1: p.end_quarter - 1, cost_kr: p.cost_kr ?? null })) .sort((a, b) => a.i0 - b.i0), }; }); // Plan-poster utan motsvarande settings-last visas BARA när settings- // payloaden är HÄMTAD och saknar deferrable_loads helt (äldre backend). // Finns arrayen är den sanningskälla: en post vars last tagits bort eller // avaktiverats är en cache-rest från /ml/analysis och ska INTE ritas. // // "INTE HÄMTAT ÄN" ÄR INTE "ÄLDRE BACKEND" (3/9). settings kommer async, och // med settings === null blev cfgIsAuthoritative false — då ritades VARJE // plan-post som en orphan, alltså också laster användaren stängt av eller // tagit bort. Det syntes vid varje sidladdning tills settings landade, och // rapporterades som "inaktiverade laster visas i planen". Samma vakt som // GcLast har för sin lista: utan settings vet vi ingenting, och då påstår // vi ingenting. const cfgIsAuthoritative = !settings || Array.isArray(settings.deferrable_loads); if (!cfgIsAuthoritative) planArr.filter(p => !usedKeys.has(p)).forEach((p, idx) => { if (p.start_quarter == null || p.end_quarter == null || p.end_quarter <= p.start_quarter) return; const existing = loads.find(l => l.key === `plan-${p.load_id ?? p.name}`); const band = { i0: p.start_quarter, i1: p.end_quarter - 1, cost_kr: p.cost_kr ?? null }; if (existing){ existing.bands.push(band); existing.bands.sort((a,b)=>a.i0-b.i0); return; } loads.push({ key: `plan-${p.load_id ?? p.name ?? idx}`, id: p.load_id, name: p.name || 'Last', power_kw: p.power_kw, earliest: null, latest: null, bands: [band], }); }); if (!ev.length && !loads.length && !evPending) return null; // §3.2: EV-orsaken (`why` + `closest_miss_kwh`) skrivs med i ev_plan när // planen byggs — finns ÄNNU INTE, läses defensivt. Toppnivå analysis får // också bära fälten om backend väljer den vägen. const _why = (evPlan && evPlan.why) || analysis.ev_why || null; const _miss = (evPlan && evPlan.closest_miss_kwh != null) ? evPlan.closest_miss_kwh : (analysis.ev_closest_miss_kwh != null ? analysis.ev_closest_miss_kwh : null); return { ev, evSessions, evUnmetKwh, evTotalKwh, evPending, evActual, evWhy: _why ? String(_why) : null, evClosestMissKwh: (typeof _miss === 'number' && isFinite(_miss)) ? _miss : null, evMinKw: _evMinKw(settings), evName: settings?.ev_charging?.name || null, loads }; } // Min-blocket i kW ur boxens lägsta laddström: A × 230 V × faser / 1000 — // samma formel som backend. Används i dead_zone-texten ("behöver minst 4,1 kW"). // Faser per bil (2026-08-04) vinner över boxens när sessionen pekar ut en bil. function _evMinKw(settings){ const chargers = Array.isArray(settings?.ev_chargers) ? settings.ev_chargers : []; const box = chargers[0] || settings?.ev_charging || null; if (!box) return null; const a = Number(box.min_current_a); if (!(a > 0)) return null; const carId = settings?.ev_session?.car_id ?? box.default_car_id ?? null; const car = (Array.isArray(settings?.ev_cars) ? settings.ev_cars : []) .find(c => c && String(c.id) === String(carId)) || null; const carPh = Number(car && car.phases); const ph = (carPh === 1 || carPh === 3) ? carPh : (Number(box.phases) === 1 ? 1 : 3); return (a * 230 * ph) / 1000; } // ---- §4.2 Orsakstexter (SPEC_VARFOR_INTE_PLANERAD, färdiga strängar) ----- // Regeln för ALLA texter: (1) att det inte går, (2) hur mycket som saknas i // samma enhet som användaren ställer in, (3) vad man kan göra. // Returnerar {head, body} — head i status-kolumnen, body på raden. // Dämpad varningsfärg (#E8C66B), aldrig rött larm: det är information. const _h = (n) => (Math.round(Number(n) * 10) / 10).toString().replace('.', ','); function loadReasonText(reason, detail, load){ const d = detail || {}; const need = d.behover_h != null ? `${_h(d.behover_h)} h` : null; const got = d.hittar_h != null ? `${_h(d.hittar_h)} h` : null; switch (reason){ case 'otillrackligt_soloverskott': return { head: 'Kan inte köras i dag', body: `behöver ${need || 'hela körtiden'} med solöverskott, prognosen ger ${got || 'mindre'}. Sänk körtiden eller tillåt nätladdning.` }; case 'fonstret_for_kort': return { head: 'Hinner inte i dag', body: `behöver ${need || 'hela körtiden'}, det är ${got || 'kortare tid'} kvar av tidsfönstret. Planeras till i morgon.` }; case 'effekttak': return { head: 'Kan inte köras nu', body: 'huset ligger nära huvudsäkringens gräns i fönstret.' }; case 'for_fa_delar': return { head: 'Kan inte köras i dag', body: `solen räcker men bara i korta perioder; tillåt fler delar${load && load.max_parts ? ` (nu max ${load.max_parts})` : ''}.` }; default: return null; } } // EV: samma tre krav. minKw/missKwh saknas ⇒ texten sägs utan tal. function evReasonText(why, opts){ const o = opts || {}; switch (why){ case 'dead_zone': { const min = o.minKw > 0 ? `${_h(o.minKw)} kW` : null; const reach = (o.minKw > 0 && o.missKwh != null) ? `${_h(Math.max(0, o.minKw - o.missKwh * 4))} kW` : null; return { head: 'Väntar på mer sol', body: min ? `bilen behöver minst ${min} överskott för att starta${reach ? `, i dag når prognosen ${reach}` : ''}.` : 'överskottet räcker inte för att starta laddningen i dag.' }; } case 'no_sun': return { head: 'Inget solöverskott i dag', body: 'prognosen ger inget överskott att ladda på.' }; case 'absent': return { head: 'Bilen verkar inte inkopplad', body: 'ingen effekt mättes under laddningen — kontrollera kabeln.' }; case 'target_reached': return { head: 'Målet är nått', body: 'ingen mer laddning behövs.' }; case 'no_session': return { head: 'Ingen laddning sparad', body: 'ange nuvarande nivå och avresetid för att planera.' }; default: return null; } } // ---- Demo-plan ur priserna (mock-läge / tweaks) ------------------------- function _cheapestRun(slots, from, to, len){ let best = null, bestSum = Infinity; for (let i = from; i + len - 1 <= to; i++){ let sum = 0; for (let j = 0; j < len; j++) sum += slots[i+j]?.buy ?? 999; if (sum < bestSum){ bestSum = sum; best = i; } } return best; } function demoEvLoadPlan(slots, nowIdx, opts){ if (!slots || slots.length < 96) return null; const now = nowIdx ?? 0; const last = Math.min(slots.length - 1, 191); // Bygg ett analysis-fragment i EXAKT den frysta fältformen och låt // buildEvLoadPlan tolka det — demo och live delar då samma kodväg. const analysis = {}; const settings = { deferrable_loads: [] }; if (opts?.ev){ const intervals = []; if (now >= 34) intervals.push({ start_quarter: now - 12, end_quarter: now - 6, kwh_per_quarter: 2.75 }); const depQ = Math.min(last, 96 + 28); const start = _cheapestRun(slots, Math.min(now + 2, last - 10), depQ, 10); if (start != null) intervals.push({ start_quarter: start, end_quarter: start + 10, kwh_per_quarter: 1.725 }); analysis.ev_plan = { intervals, total_kwh: intervals.reduce((a,iv)=>a+(iv.end_quarter-iv.start_quarter)*iv.kwh_per_quarter, 0), unmet_kwh: 0 }; // Demo-facit i EXAKT ev_actual-formen (dag-lokala q-nycklar) → samma kodväg // som live: laddat men klippt effekt, två kvartar helt uteblivna, samt en // oplanerad laddning strax efter (Ladda nu). const perQ = {}; intervals.filter(iv => iv.start_quarter < 96 && iv.end_quarter <= now).forEach(iv => { for (let i = iv.start_quarter; i < iv.end_quarter; i++){ const skip = i >= iv.start_quarter + 2 && i < iv.start_quarter + 4; // miss-fall if (!skip) perQ[`q${i}`] = Math.round(iv.kwh_per_quarter * 0.78 * 1000) / 1000; } }); if (now >= 6){ perQ[`q${now-4}`] = 0.74; perQ[`q${now-3}`] = 0.74; } // utanför plan if (Object.keys(perQ).length){ analysis.ev_actual = { date: (typeof window.hemsToday === 'function') ? window.hemsToday() : null, per_charger: { ev_1: perQ }, }; } } if (opts?.loads){ const from = Math.max(now + 1, 36); // tidigast 09:00 const start = _cheapestRun(slots, Math.min(from, 78), 84, 6); // senast klar 21:00 settings.deferrable_loads = [{ id:'demo-vvb', name:'Varmvattenberedare', power_kw:3.0, duration_hours:1.5, earliest:'09:00', latest:'21:00', enabled:true }]; analysis.deferrable_plan = start != null ? [{ load_id:'demo-vvb', name:'Varmvattenberedare', power_kw:3.0, start_quarter:start, end_quarter:start + 6, day: start >= 96 ? 1 : 0, cost_kr: slots.slice(start, start+6).reduce((a,s)=>a + (s.buy||0)/100 * 3.0 * 0.25, 0), }] : []; } return buildEvLoadPlan(analysis, settings); } // Deadline byggs av TVÅ objekt (backend-svar 2026-08-07): tiden bor i // ev_session (fallback laddarens departure_time), DAGEN bor i laddar- // konfigurationen (departure_day). Läser kortet bara sessionen lovar det // "klar till 07:00" i morgon när backend menar måndag om tre dygn. const EV_DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat']; const EV_DAY_SV = { mon:'måndag', tue:'tisdag', wed:'onsdag', thu:'torsdag', fri:'fredag', sat:'lördag', sun:'söndag' }; const evHHMM = (dt) => String(dt.getHours()).padStart(2,'0') + ':' + String(dt.getMinutes()).padStart(2,'0'); // "9:05" och "09:05" är samma tid — jämför alltid padded, annars ser en // oförändrad tid dirty ut och stämplas som override. const evPad = (t) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '').trim()); return m ? String(m[1]).padStart(2,'0') + ':' + m[2] : ''; }; // resolve_next_departure: nästa tidpunkt som matchar dag + tid. Utan dag = // nästa gång klockslaget passeras (dagens beteende). const evResolveDeparture = (day, hhmm, now) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '')); if (!m) return null; const d = new Date(now); d.setHours(Number(m[1]), Number(m[2]), 0, 0); const idx = EV_DAY_KEYS.indexOf(String(day || '')); if (idx < 0){ if (d <= now) d.setDate(d.getDate() + 1); return d; } let add = (idx - d.getDay() + 7) % 7; if (add === 0 && d <= now) add = 7; d.setDate(d.getDate() + add); return d; }; const evDeadlineLabel = (dt, now) => { if (!dt) return null; const a = new Date(now); a.setHours(0,0,0,0); const b = new Date(dt); b.setHours(0,0,0,0); const days = Math.round((b - a) / 86400000); if (days <= 0) return evHHMM(dt); if (days === 1) return 'i morgon ' + evHHMM(dt); return EV_DAY_SV[EV_DAY_KEYS[dt.getDay()]] + ' ' + evHHMM(dt); }; // ---- EV-kort (Översikt) ------------------------------------------------- // ewNextDeadline ger index i sin egen måndagsstartade lista — översätt till // våra dagnycklar så evResolveDeparture kan användas med overridens tid. const EV_DAY_KEYS_FROM_WEEK = ['mon','tue','wed','thu','fri','sat','sun']; const EvCard = ({ cfg, chargerId, name, live, liveEvW, evLoad, nowIdx, session, loadInfo, outsidePlan, tomorrowPrelim, cars, car, settings, allChargers, onSaved }) => { // Efter en sparning ligger den nya laddarkonfigurationen i Firestore, men // `cfg` kommer från senaste settings-hämtningen (upp till 120 s gammal). // Utan överlagring föll kortet tillbaka på gamla departure_day och hoppade // till "endast sol" — nästa sparning hade då nollat avresedagen (2026-08-08). const [savedCh, setSavedCh] = React.useState(null); const c = savedCh ? { ...(cfg || {}), ...savedCh } : (cfg || {}); // Släpp överlagringen så snart servern svarar med samma värden. React.useEffect(() => { if (!savedCh) return; const inc = cfg || {}; if (Object.keys(savedCh).every(k => String(inc[k] ?? '') === String(savedCh[k] ?? ''))) setSavedCh(null); }, [cfg, savedCh]); const phases = Number(c.phases) === 1 ? 1 : 3; const f1 = (n) => (n ?? 0).toFixed(1).replace('.', ','); // Per-laddbox localStorage-nyckel så två bilar inte skriver över varandra. const lsKey = 'hems_ev_session' + (chargerId != null && chargerId !== '' ? '_' + chargerId : ''); // Dagliga session-värden — förifylls från settings, override per tillfälle. // Persisteras lokalt så en sidladdning inte tappar inmatningen; backend är // sanningskällan när PATCH-endpointen svarar live. const [sess, setSess] = React.useState(() => { // Backend-session (per charger_id) är sanningskälla vid laddning; faller // tillbaka på lokalt sparat, annars tomt. if (session && typeof session === 'object'){ return { current_pct: session.current_pct ?? null, departure_time: session.departure_time ?? null, target_pct: session.target_pct ?? null, car_id: session.car_id ?? null, }; } try { const saved = JSON.parse(localStorage.getItem(lsKey) || 'null'); if (saved && typeof saved === 'object') return saved; } catch(e){} return { current_pct: null, departure_time: null, target_pct: null, car_id: null }; }); const [draft, setDraft] = React.useState(null); // null = inga osparade ändringar const [toast, setToast] = React.useState(null); // "Ladda nu" — direktladdning oavsett pris/schema (backend håller sig under // effekttaket). Backend-sanning ligger i ev_session.charge_now_until (framtida // tidpunkt = pågår). chargeNowOpt = optimistiskt override tills nästa // settings-poll bekräftar. const [chargeNowOpt, setChargeNowOpt] = React.useState(null); // ms-tid | 'off' | null // En stabil nu-tid per render: veckoschemats deadline och kortets etiketter // måste räknas mot SAMMA tidpunkt, annars kan de hamna på olika sida om en // kvartsgräns. const nowTsRef = React.useRef(new Date()); nowTsRef.current = new Date(); const sessDep = sess.departure_time || null; const target = sess.target_pct ?? c.target_pct ?? 80; const curPct = sess.current_pct; const connected = curPct != null && curPct !== ''; // Bilprofiler (SPEC_BILPROFILER_PER_BOX): bilen kan skilja sig från boxen. // sessionens car_id vinner, annars boxens fasta bil (löst i EvCards). const carList = Array.isArray(cars) ? cars : []; const carLabel = (x, i) => (x && x.name && String(x.name).trim()) || `Bil ${((i != null ? i : carList.indexOf(x)) || 0) + 1}`; const activeCar = carList.find(x => String(x.id) === String(sess.car_id)) || car || null; // Bilväljaren visas så snart det finns mer än en bil — det är HÄR laddningen // planeras, så här måste man också kunna byta bil (2026-07-30). Boxens // car_mode styr bara vilken bil som är FÖRVALD, inte om valet finns. const carPick = carList.length > 1; const carFixed = (window.evModel?.carModeFor ? window.evModel.carModeFor(c) : c.car_mode) !== 'select'; // SPEC_EV_SOLAR_MODE-rättelse (2026-07-23): i sol-läge är avresetid VALFRI. // Garanti = avresedag vald i settings (c.departure_day). "klar till" binds // till backendens deadline (skickas ENBART när garanti-dag satt), inte till // sessionens departure_time. Saknas deadline → ingen "klar till". const solarMode = c.solar_mode === true; // Produktregel (André 2026-08-07): BÅDA lägena är sol-läge i backend. // Dagen är strömbrytaren — solar_mode:true utan avresedag = endast sol // (aldrig nät), med avresedag = sol tills dagen före avresan, sedan nät // eller batteri som garanti. Legacy solar_mode:false visas som "Sol och // billig el" och migreras till true först när användaren rör inställningen. // // VECKOSCHEMA (SPEC §12): departure_mon…departure_sun är valfria "HH:MM". // Minst en satt dag ⇒ schemat gäller och räknas som avresedag, alltså samma // garantiöppning som departure_day. Utan den här läsningen påstod kortet // "aldrig från elnätet" medan backend hade en öppen garanti (2026-08-16). const weekPlan = window.ewNextDeadline ? window.ewNextDeadline(c, nowTsRef.current) : null; const hasWeek = !!weekPlan; /* FÖRIFYLLNADEN. Veckoschemats tid går FÖRE boxens departure_time — boxens är per kontraktet bara fallback när ingen dag är satt. Läste vi den först visade panelen 10:00 medan rutnätet sa onsdag 12:00, och sparningen nedan stämplade den felaktiga tiden som skarp override (Magnus fdbd, 23/8 17:13: fönstret krympte två timmar och nätköpen tidigarelades, utan att han rört ett tidsfält). Ordning: sessionens egen override, veckoschemats dag, boxens vilande standard. */ const dep = sessDep || (weekPlan && weekPlan.time) || c.departure_time || '07:00'; // Vad användaren har valt i segmenten — påverkas INTE av veckoschemat. const sunOnlySetting = solarMode && !c.departure_day; // Vad som faktiskt gäller: en satt veckodag öppnar garantin även i sol-läge. const sunOnly = sunOnlySetting && !hasWeek; const guarantee = !sunOnly; const nowTs = nowTsRef.current; // Backendens egen deadline vinner när den finns. Annars: veckoschemat före // ett-värdet, eftersom schemat är det som gäller när det är satt. Overriden // (session.departure_time) byter TIDEN på schemats dag, aldrig dagen. const bDl = session?.deadline ? new Date(session.deadline) : null; const weekAt = hasWeek ? (sessDep && sessDep !== weekPlan.time ? evResolveDeparture(EV_DAY_KEYS_FROM_WEEK[weekPlan.dayIdx], sessDep, nowTs) : weekPlan.at) : null; const deadlineAt = guarantee ? ((bDl && !isNaN(bDl)) ? bDl : (weekAt || evResolveDeparture(c.departure_day, dep, nowTs))) : null; const deadline = deadlineAt ? evDeadlineLabel(deadlineAt, nowTs) : null; const weekLbl = hasWeek ? (EV_DAY_SV[EV_DAY_KEYS_FROM_WEEK[weekPlan.dayIdx]] + ' ' + (sessDep || weekPlan.time)) : null; // Planeringshorisonten är 192 kvartar (48 h) — ligger avresan längre bort // finns ingen laddplan att visa än. Utan förklaring läses det som att valet // inte fastnade. // Hinten visas BARA när det verkligen saknas plan — backend kan redan ha // lagt segment inom horisonten, och då motsade hinten grafen (2026-08-07). const planFromAt = deadlineAt && (deadlineAt - nowTs > 48*3600*1000) && !((evLoad?.ev || []).length) ? new Date(deadlineAt.getTime() - 48*3600*1000) : null; // charge_now_until kommer i settings/analysis-svaret (ev_session). Finns den // och ligger i framtiden → direktladdning pågår. Backend stoppar själv vid mål. const parseUntil = (v) => { if (v == null || v === '') return null; const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? t : null; }; const backendUntil = parseUntil(session?.charge_now_until); const untilTs = chargeNowOpt === 'off' ? null : chargeNowOpt != null ? chargeNowOpt : backendUntil; const chargeNowActive = untilTs != null && untilTs > Date.now(); // Nästa settings-poll är sanning → släpp det optimistiska overridet. React.useEffect(() => { setChargeNowOpt(null); }, [session?.charge_now_until]); // Synka visade värden när backend-sessionen ändras (om inga osparade ändringar). React.useEffect(() => { if (session && typeof session === 'object' && !draft){ setSess(s => ({ current_pct: session.current_pct ?? s.current_pct, departure_time: session.departure_time ?? s.departure_time, target_pct: session.target_pct ?? s.target_pct, car_id: session.car_id ?? s.car_id, })); } }, [session?.current_pct, session?.target_pct, session?.departure_time, session?.car_id]); // Live-last per bil: ev_power_list → loads[].power_w → skalär ev_power. const evW = liveEvW != null ? liveEvW : (loadInfo && loadInfo.power_w != null ? Number(loadInfo.power_w) : (live?.ev_power || 0)); const charging = evW > 100; // Backend ger uppmätt ström per box — använd den före vår uppskattning. const ampsNow = (loadInfo && loadInfo.current_a != null && Number(loadInfo.current_a) > 0) ? Math.round(Number(loadInfo.current_a)) : (charging ? Math.max(1, Math.round(evW / (230 * phases))) : null); const futureSeg = (evLoad?.ev || []).find(g => g.i0 > (nowIdx ?? 0)); // §3a: unmet_kwh > 0 = målet ryms inte före avresetid → varning. const unmetKwh = evLoad?.evUnmetKwh || 0; const battKwh = (activeCar && activeCar.battery_kwh > 0 ? Number(activeCar.battery_kwh) : 0) || c.battery_kwh || 0; const reachPct = unmetKwh > 0 && battKwh > 0 ? Math.max(0, Math.round(target - (unmetKwh / battKwh) * 100)) : null; let status; if (charging){ status = { color:'var(--c-sell)', live:true, txt:`Laddar ${ampsNow} A (${f1(evW/1000)} kW)` }; } else if (!connected){ status = { color:'var(--ink-3)', txt:'Ej inkopplad' }; } else if (curPct >= target){ status = { color:'var(--c-sell)', txt:`Klar — ${target} % nås till ${deadline || dep}` }; } else if (futureSeg){ status = { color:'var(--blue)', txt:`Väntar på billig el — startar ~${fmt.hhmm(futureSeg.i0 % 96)}` }; } else if (evLoad?.evPending){ status = { color:'var(--ink-2)', txt:`Väntar på planering — klar till ${deadline || dep}` }; } else { status = { color:'var(--ink-2)', txt:`Inkopplad — laddas klart till ${deadline || dep}` + (hasWeek ? ' (veckoschema)' : '') }; } // Direktladdning vinner över allt annat i statusraden. if (sunOnly){ // SPEC_EV_SOLAR_MODE §2 — reasons rakt av (backend-mönster). if (charging) status = { color:'var(--solar)', live:true, txt:`Laddar ${ampsNow} A med solöverskott` }; else if (connected) status = { color:'var(--ink-2)', txt:'Väntar på solöverskott' }; } if (chargeNowActive){ status = { color:'var(--c-sell)', live:true, txt:`Laddar nu — direkt till ${target} %` }; } // Nattplan-hint: deadline ligger i morgon (avresetid redan passerad idag) och // morgondagens priser är inte publicerade → planen väntar medvetet på Nord Pool. const depQ = deadlineAt ? (deadlineAt.getHours() * 4 + Math.floor(deadlineAt.getMinutes() / 15)) : null; const nightPlanPending = tomorrowPrelim === true && depQ != null && nowIdx != null && depQ <= nowIdx && !charging; // SPEC_EV_LADDSATT_I_PLANEN (2026-08-07): laddsättet hör hemma där planen // skapas. Fälten ÄR desamma som i inställningar — laddboxens solar_mode/ // departure_day/departure_time och det flata ev_pause_battery. Inget nytt // backend-kontrakt; inställningssidan är oförändrad och speglar värdena. const pauseBattery = (settings && (settings.ev_pause_battery ?? settings.pause_battery_on_ev_charging)) === true; const d = draft || { current_pct: curPct ?? '', departure_time: dep, target_pct: target, car_id: activeCar ? activeCar.id : null, mode: sunOnlySetting ? 'sun' : 'classic', departure_day: c.departure_day || '', pause_battery: pauseBattery, solar_priority: c.solar_priority || 'car_first' }; const setD = (patch) => setDraft({ ...d, ...patch }); // "Endast sol" har ingen deadline — varken dag eller tid skickas. Klassiskt // läge KRÄVER en avresedag; utan den vore läget identiskt med rent sol-läge. const guaranteeD = d.mode !== 'sun'; // Veckoschemat räknas som avresedag, så Spara ska inte blockeras när det är // satt — dagvalet här är då bara ett komplement. const dayMissing = d.mode !== 'sun' && !d.departure_day && !hasWeek; const save = async () => { const session = { current_pct: d.current_pct === '' ? null : Math.max(0, Math.min(100, Number(d.current_pct))), target_pct: Math.max(0, Math.min(100, Number(d.target_pct) || 80)), }; // Bilprofil: car_id skickas när bilen är känd — backend planerar mot DEN // bilens batteri. Utelämnas den faller backend tillbaka på boxens // default_car_id/battery_kwh (exakt dagens beteende). if (d.car_id) session.car_id = d.car_id; /* SPARNINGEN. Tiden skickas BARA när användaren själv ändrat den, och ALDRIG när veckorutnätet är i bruk — samma regel som gc-bil.jsx (wantSessTime = null när weekSet). Tidigare stämplades tiden vid varje spara i klassiskt läge, så att bara ange SOC skrev om avresetiden till förifyllnadens värde. Sessionens tid går in som override i backends veckoresolver (ev_feasibility.py:459) och VINNER över schemadagens tid — avsiktlig semantik, men den förutsätter ett val, inte ett eko av en default. "Ingen tid" uttrycks som UTELÄMNAD nyckel, aldrig null: null ger 422 (string_type). PATCH:en ersätter hela ev_sessions[ev_n], så en utelämnad nyckel rensar en gammal override — önskat här. */ /* Dirty-tracking ENSAMT gatar overriden — inte "aldrig i veckoläge". Fältet "Klar senast — tid — den här gången" är synligt och redigerbart när rutnätet är i bruk, och per-tillfälle-overriden ÄR avsiktlig semantik (ev_feasibility.py:459). Med !hasWeek här hade fältet gett en bekräftelsetoast utan verkan. Rör användaren inte tiden är den inte dirty, nyckeln utelämnas, och rutnätets tid gäller — vilket var hela buggen. */ const depDirty = !!d.departure_time && evPad(d.departure_time) !== evPad(dep); if (guaranteeD && depDirty) session.departure_time = evPad(d.departure_time); // Laddsätt = inställningsfält, inte sessionsfält. Sparas via // saveSettingsPatch (POST av HELA settings-dokumentet — PATCH /api/settings // tar bara ev_session). const needPause = (d.pause_battery === true) !== pauseBattery; const chPatch = {}; // Båda lägena ÄR sol-läge i backend — skillnaden är dagen. if (!solarMode) chPatch.solar_mode = true; const wantDay = d.mode === 'sun' ? null : (d.departure_day || null); if (String(wantDay || '') !== String(c.departure_day || '')) chPatch.departure_day = wantDay; // Prioriteten gäller i BÅDA lägena — sol finns även i klassiskt läge // (sommartid oftast sol), så valet bil/batteri först hör hemma där också. if ((d.solar_priority || 'car_first') !== (c.solar_priority || 'car_first')) chPatch.solar_priority = d.solar_priority || 'car_first'; /* Boxens departure_time är dess VILANDE STANDARD (fallback när ingen dag är satt), inte tiden för det här tillfället. Den skrivs bara utan rutnät — exakt gc-bil:s regel: if (d.mode === 'hems' && !weekSet && d.time). Utan !hasWeek sköt förifyllnadens nya veckotid (12:00) över boxens 10:00 vid varje spara, alltså samma tysta omskrivning som backend rapporterade, flyttad från ev_session till ev_chargers[k]. Jämförelsen är padded: "9:05" och "09:05" är samma tid. */ if (guaranteeD && !hasWeek && d.departure_time && evPad(d.departure_time) !== evPad(c.departure_time)) chPatch.departure_time = evPad(d.departure_time); const needCh = Object.keys(chPatch).length > 0; // Byggs mot det FÄRSKA dokumentet inuti saveSettingsPatch, aldrig mot den // kopia sidan laddade (krockfönstret krymper från minuter till ms), och // varje laddarpost bär SAMTLIGA sina fält — en partiell post nollställer // battery_kwh, name, solar_mode, target_pct, enabled, car_mode, // default_car_id och departure_time (backend-verifierat 2026-08-07). const buildPatch = (full) => { const p = {}; if (needPause) p.ev_pause_battery = d.pause_battery === true; if (needCh){ const list = full && Array.isArray(full.ev_chargers) ? full.ev_chargers : null; if (!list || !list.length) throw new Error('Laddboxlistan saknas i settings — laddsättet sparades inte'); if (!list.some(x => x && String(x.id) === String(chargerId))) throw new Error(`Laddboxen ${chargerId} finns inte i settings — laddsättet sparades inte`); p.ev_chargers = list.map(x => String(x && x.id) === String(chargerId) ? { ...x, ...chPatch } : x); } return p; }; try { setToast({ kind:'info', msg:'Sparar…' }); // Sessionen först: den måste kunna sparas även om laddsättet avvisas. await window.hems.patchEvSession(session, chargerId); if (needPause || needCh){ try { await window.hems.saveSettingsPatch(buildPatch); } catch(e2){ setSess(session); try { localStorage.setItem(lsKey, JSON.stringify(session)); } catch(e3){} setDraft(null); setToast({ kind:'err', msg:'SoC och tid sparade — laddsättet kunde inte sparas: ' + (e2 && e2.message ? String(e2.message).slice(0,120) : 'okänt fel') }); setTimeout(() => setToast(null), 9000); return; } } setSess(session); try { localStorage.setItem(lsKey, JSON.stringify(session)); } catch(e){} if (needCh) setSavedCh({ ...chPatch }); setDraft(null); if (onSaved) onSaved(); // UX-krav 2026-06-12: bekräfta EXPLICIT att sessionen är aktiv och // planeras — "Sparat" ensamt läste användare som att inget hände. setToast({ kind:'ok', msg: d.car_id && carList.length > 1 ? `Sparat — ${carLabel(carList.find(x => String(x.id) === String(d.car_id)))} laddas, planeras inom ~15 min` : 'Sparat — sessionen är aktiv, laddningen planeras inom ~15 min' }); } catch(e){ setToast({ kind:'err', msg:'Kunde inte spara' + (e && e.message ? ' — ' + String(e.message).slice(0,120) : '') }); } setTimeout(() => setToast(null), 6000); }; // “Ladda nu”/“Avbryt” — måste skicka HELA ev_session (endpointen ersätter // mappen; utelämnade fält nollas), så vi bär med befintliga sessionfält. const sessionBase = () => ({ current_pct: (curPct == null || curPct === '') ? null : Math.round(Number(curPct)), departure_time: dep, target_pct: Math.round(Number(target)) || 80, ...(sess.car_id ? { car_id: sess.car_id } : {}), }); const chargeNowStart = async () => { setChargeNowOpt(Date.now() + 360 * 60000); // optimistiskt, default 6 h try { setToast({ kind:'info', msg:'Startar direktladdning…' }); await window.hems.patchEvSession({ ...sessionBase(), charge_now_minutes: 360 }, chargerId); setToast({ kind:'ok', msg:`Laddar direkt till ${sessionBase().target_pct} %` }); } catch(e){ setChargeNowOpt(null); setToast({ kind:'err', msg:'Kunde inte starta' }); } setTimeout(() => setToast(null), 6000); }; const chargeNowCancel = async () => { setChargeNowOpt('off'); try { setToast({ kind:'info', msg:'Avbryter…' }); await window.hems.patchEvSession({ ...sessionBase(), charge_now_minutes: 0 }, chargerId); setToast({ kind:'ok', msg:'Direktladdning avbruten' }); } catch(e){ setChargeNowOpt(null); setToast({ kind:'err', msg:'Kunde inte avbryta' }); } setTimeout(() => setToast(null), 6000); }; return (

{name || 'Elbil'}

{/* Vilken BIL som sitter i boxen — visas bara när det finns mer än en bil att förväxla den med (spec §1: noll ny friktion för majoriteten). */} {activeCar && carList.length > 1 && ( {carLabel(activeCar)} )} {sunOnly ? 'Laddar med endast sol' : 'Sol och billig el'} {sunOnly && ( ☀ Sol )} {outsidePlan && ( Laddar utanför HEMS styrning )}
{status.txt}
{/* SOC-progress: nu → mål, deadline */}
{connected ? `${Math.round(curPct)} %` : '— %'} {target} % {deadline ? klar till {deadline} : mål {target} % — laddas med sol}
{loadInfo && (charging || loadInfo.reason || loadInfo.next_charge_at) && (
{charging && {f1(evW/1000)} kW{ampsNow ? · {ampsNow} A : null}} {loadInfo.estimated_pct != null && ( uppskattad laddning {Math.round(Number(loadInfo.estimated_pct))} % )} {!charging && loadInfo.next_charge_at && ( nästa laddning {String(loadInfo.next_charge_at).slice(11,16) || loadInfo.next_charge_at} )} {loadInfo.reason && {loadInfo.reason}}
)} {planFromAt && (
Laddplanen visas från {evDeadlineLabel(planFromAt, nowTs)} — avresan ligger längre bort än planeringshorisonten (48 h)
)} {nightPlanPending && (
Nattens laddning planeras när morgondagens elpriser publicerats (~13:00)
)} {unmetKwh > 0 && (
Hinner till {reachPct != null ? `~${reachPct} %` : `~${target} % minus ${unmetKwh.toFixed(1).replace('.',',')} kWh`} — höj laddström eller senare avresa
)}
{/* “Ladda nu” (§ direktladdning) — visas ALLTID. Vi kan inte veta om bilen är inkopplad, så användaren måste alltid kunna tvinga igång laddning. Startar omedelbar laddning oavsett pris/schema. */}
{chargeNowActive ? ( ) : ( )} {chargeNowActive ? 'Laddar direkt oavsett pris — stoppar vid målet (auto efter 6 h)' : `Starta direkt tills ${target} % — oavsett pris, under effekttaket`}
{/* Snabbjustering — dagliga värden (PATCH ev_session). UX-krav 2026-06-12: laddningen planeras FÖRST efter Spara — osparade ändringar måste vara omöjliga att missa (dirty-ram + hint + attn). */}
{/* Bilväljare FÖRE SOC-fältet (spec §2): boxen är satt till "fråga varje gång" och det finns flera bilar. En box = en session = en bil åt gången — byte skrivs som en ny session vid Spara. */} {carPick && (
{carFixed ? 'Bil' : 'Vilken bil står här nu?'}
{carList.map((x, i) => ( ))}
)} {/* Laddsätt — allt som styr HUR bilen laddas, på samma ställe som planen skapas. Samma fält som i inställningar (speglade). */}
Så här ska den laddas
{/* Produktmodell (André 2026-08-07): "endast sol" = ALDRIG nät och därmed ingen deadline alls — dag och tid göms helt. Klassiskt läge har dag + tid; backend honorerar departure_day där sedan HEMS_EV_CLASSIC_DEPARTURE_DAY. */} {hasWeek && ( )} {d.mode !== 'sun' && !hasWeek && } {(d.mode !== 'sun' || hasWeek) && ( )} {/* solar_priority flyttad hit från inställningar — den styr samma sak som "Pausa hembatteriet" och hör därför ihop med den. Visas i BÅDA lägena (krav 2026-08-07). Bor i ev_chargers-posten (needCh), pausen är ett toppfält (needPause). */} {( )} {/* Texten styrs av ev_pause_battery + battery_control_class (backend-kontrakt 30/8). Kryssrutan är SAMMA bool som förut; klassen är en informationsrad, inte ett val — den sätts av backend efter mätning. self_use får dessutom varningen om att egenförbrukningsläge inte följer HEMS effekt. Okänd/saknad klass ⇒ generiska paustexten (ai-klassen har inget eget löfte ännu — den är obevisad under verklig billast). */} {(() => { const cls = (settings && settings.battery_control_class) || null; const on = d.pause_battery === true; const txt = !on ? 'Batteriet får ladda bilen' : cls === 'setpoint' ? 'Batteriet täcker bara huset när bilen laddar' : 'Batteriet pausar när bilen laddar'; const sub = !on ? 'HEMS avgör när det lönar sig att bilen laddas från husbatteriet.' : cls === 'setpoint' ? 'Solen täcker huset först, batteriet resten — aldrig bilen. Kräver att växelriktaren följer HEMS rekommenderade effekt.' : 'Batteriet varken laddar eller laddar ur medan bilen laddar.'; return (
{sub}
{on && cls === 'self_use' && (
Står växelriktaren i egenförbrukningsläge följer den inte HEMS effekt — då täcker batteriet all last, även bilen.
)}
); })()} {/* Veckoschemat är också en avresedag (SPEC §12). Är det satt gäller garantin även när "endast sol" är valt här, och texten måste säga det — annars påstår kortet "aldrig från elnätet" medan backend köper nätel (2026-08-16). */}
{hasWeek ? (d.mode === 'sun' ? `Veckoschemat sätter avresedagen till ${weekLbl}, så garantin gäller: bilen laddas med sol men fylls med nätel om solen inte räcker fram till dess.` : `Veckoschemat sätter avresedagen till ${weekLbl}. Bilen laddas med sol fram till dagen före, sedan fylls resten med billig nätel eller ur hembatteriet.`) : d.mode === 'sun' ? 'Bilen laddas enbart ur solöverskott, aldrig från elnätet — ingen avresetid gäller, och utan sol nås inte målet.' : dayMissing ? 'Välj avresedag — utan den blir läget detsamma som endast sol.' : 'Bilen laddas med sol fram till dagen före avresan. Är den inte full då fylls resten med billig nätel eller ur hembatteriet.'}
{draft && !toast && ( Osparat — tryck Spara för att laddningen ska planeras )} {toast && {toast.msg}}
); }; // Multi-EV (§5): container — en EvCard per laddbox. Sida vid sida på desktop // (när 2 bilar), staplat på mobil. liveEvW per box ur live.ev_power_list; // session per charger_id via evModel.sessionFor. Bakåtkompat: en box → samma // vy som tidigare, noll boxar → inget kort. const EvCards = ({ chargers, live, evLoad, nowIdx, settings, evWList, tomorrowPrelim, onSaved }) => { if (!Array.isArray(chargers) || chargers.length === 0) return null; const single = chargers.length === 1; return (
{chargers.map((c, i) => { const name = (window.evModel && window.evModel.displayName(c, i)) || (c.name || `Bil ${i+1}`); let liveEvW; if (Array.isArray(evWList) && evWList.length > i){ liveEvW = Number(evWList[i]) || 0; } else { const lst = live && live.ev_power_list; if (Array.isArray(lst) && lst.length > i) liveEvW = Number(lst[i]) || 0; else if (i === 0 && live && live.ev_power != null) liveEvW = live.ev_power; } const session = window.evModel ? window.evModel.sessionFor(settings, c, i) : null; // Per-bil live-last + "laddar utanför styrning"-flagga ur live.loads[]. const loadInfo = window.evModel?.liveLoadFor ? window.evModel.liveLoadFor(live, c, i) : null; const outsidePlan = window.evModel?.outsidePlanFor ? window.evModel.outsidePlanFor(live, c, i, loadInfo) : false; return ( ); })}
); }; const __evStyles = document.createElement('style'); __evStyles.textContent = ` .ev-cards{display:grid;gap:16px;grid-template-columns:1fr} @media(min-width:1024px){ .ev-cards:not(.single){grid-template-columns:1fr 1fr} } .evcard .ev-status{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:500} .evcard .ev-status .d{width:7px;height:7px;border-radius:50%;flex:none} .evcard .ev-status .d[data-live]{animation:glcPulse 2s ease-out infinite} .evcard .ev-body{padding:16px 22px 18px;display:flex;flex-direction:column;gap:16px} .evcard .ev-soc-row{display:flex;align-items:baseline;gap:10px;margin-bottom:9px} .evcard .ev-soc-row .big{font-size:26px;font-weight:300;letter-spacing:-.02em;color:var(--ink)} .evcard .ev-soc-row .big.tgt{color:var(--c-sell)} .evcard .ev-soc-row .arrow{color:var(--ink-3);font-size:16px} .evcard .ev-soc-row .dl{margin-left:auto;font-size:11.5px;color:var(--ink-2)} .evcard .ev-soc-row .dl b{color:var(--ink);font-weight:500} .evcard .ev-bar{position:relative;height:9px;border-radius:6px;background:rgba(255,255,255,.05);border:1px solid var(--line);overflow:visible} .evcard .ev-bar .fill{height:100%;border-radius:6px;background:linear-gradient(90deg,rgba(91,168,255,.55),#5BA8FF);transition:width .4s} .evcard .ev-bar .tgt-mark{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--c-sell);border-radius:2px} .evcard .ev-warn{display:flex;align-items:center;gap:8px;margin-top:10px;padding:8px 11px;border-radius:9px; background:rgba(244,200,75,.08);border:1px solid rgba(244,200,75,.28);color:#E8C66B;font-size:11.5px} .evcard .ev-warn .tri{font-size:9px;flex:none} .evcard .ev-outside{display:inline-flex;align-items:center;gap:6px;margin-left:8px;padding:2px 9px;border-radius:20px; font-size:10.5px;font-weight:600;letter-spacing:.02em;color:#FF9E9E;background:rgba(255,107,107,.12);border:1px solid rgba(255,107,107,.38)} .evcard .ev-outside .tri{font-size:8px} .evcard .ev-live-row{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-top:10px;font-size:11.5px;color:var(--ink-2)} .evcard .ev-live-row b{color:var(--ink);font-weight:500} .evcard .ev-live-row .r{color:var(--ink-3)} .evcard .ev-night-hint{margin-top:10px;padding:8px 11px;border-radius:9px;font-size:11.5px;color:#9CC6FF; background:rgba(91,168,255,.08);border:1px solid rgba(91,168,255,.26)} .evcard .ev-adjust{display:flex;flex-wrap:wrap;align-items:flex-end;gap:14px;padding-top:14px;border-top:1px solid var(--line)} /* Bilprofiler (SPEC_BILPROFILER_PER_BOX): tagg i rubriken + bilväljare före SOC-fältet. Visas bara när det finns fler än en bil. */ .evcard .ev-car-tag{display:inline-flex;align-items:center;margin-left:8px;padding:2px 9px;border-radius:20px; font-size:10.5px;font-weight:600;letter-spacing:.02em;color:#9CC6FF;background:rgba(91,168,255,.12);border:1px solid rgba(91,168,255,.3)} .evcard .ev-car-pick{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:center;gap:10px} .evcard .ev-car-pick>span{font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--ink-3)} .evcard .ev-car-pick .opts{display:flex;flex-wrap:wrap;gap:8px} .evcard .ev-car-pick button{padding:7px 13px;border-radius:8px;font:inherit;font-size:12px;cursor:pointer; color:var(--ink-2);background:rgba(0,0,0,.25);border:1px solid var(--line)} .evcard .ev-car-pick button:hover{color:var(--ink);border-color:rgba(91,168,255,.4)} .evcard .ev-car-pick button.on{color:#9CC6FF;background:rgba(91,168,255,.14);border-color:rgba(91,168,255,.55)} .evcard .ev-adjust label{display:flex;flex-direction:column;gap:6px;font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--ink-3)} .evcard .ev-adjust .inp{display:inline-flex;align-items:center;gap:5px;background:rgba(0,0,0,.25);border:1px solid var(--line);border-radius:8px;padding:7px 10px} .evcard .ev-adjust .inp input{appearance:none;background:transparent;border:0;color:var(--ink);font:inherit;font-family:"Geist Mono",monospace;font-size:14px;width:52px;outline:none} .evcard .ev-adjust .inp input[type=time]{width:84px} .evcard .ev-adjust .inp i{font-style:normal;color:var(--ink-3);font-size:11px} .evcard .ev-save{align-self:flex-end} .evcard .ev-save:disabled{opacity:.4;cursor:default} .evcard .ev-toast{font-size:11.5px;align-self:flex-end;padding-bottom:8px} .evcard .ev-toast.ok{color:var(--c-sell)} .evcard .ev-toast.err{color:var(--c-peak)} .evcard .ev-toast.info{color:var(--ink-2)} .evcard .ev-toast.hint{color:#E8C66B} .evcard .ev-pausrow{flex-basis:100%;display:flex;flex-direction:column;gap:4px} .evcard .ev-paussub{font-size:11.5px;line-height:1.45;color:var(--ink-3);padding-left:26px} .evcard .ev-paussub.warn{color:#E8C66B} .evcard .ev-adjust.dirty .inp{border-color:rgba(244,200,75,.40)} /* Laddsätt i planen (2026-08-07) — sol/nät, klar-till dag+tid, pausa batteri. */ .evcard .ev-how{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:flex-end;gap:14px;padding-top:13px;margin-top:2px;border-top:1px solid var(--line)} .evcard .ev-how .hd{flex-basis:100%;font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--ink-3)} .evcard .ev-how .seg{display:flex;gap:8px} .evcard .ev-how .seg button{padding:7px 13px;border-radius:8px;font:inherit;font-size:12px;cursor:pointer; color:var(--ink-2);background:rgba(0,0,0,.25);border:1px solid var(--line)} .evcard .ev-how .seg button:hover{color:var(--ink);border-color:rgba(91,168,255,.4)} .evcard .ev-how .seg button.on{color:#9CC6FF;background:rgba(91,168,255,.14);border-color:rgba(91,168,255,.55)} .evcard .ev-how .seg button.on.sun{color:#F4C84B;background:rgba(244,200,75,.14);border-color:rgba(244,200,75,.55)} .evcard .ev-how .inp select{appearance:none;background:transparent;border:0;color:var(--ink);font:inherit;font-size:12.5px;outline:none;cursor:pointer;padding-right:2px} .evcard .ev-how .inp select option{background:#0E1420;color:var(--ink)} .evcard .ev-how .inp.off{opacity:.55;font-size:11.5px;color:var(--ink-3)} .evcard .ev-chk{display:inline-flex;align-items:center;gap:9px;padding:7px 13px;border-radius:8px;font:inherit;font-size:12px;cursor:pointer; color:var(--ink-2);background:rgba(0,0,0,.25);border:1px solid var(--line)} .evcard .ev-chk i{width:14px;height:14px;border-radius:4px;border:1px solid var(--line);background:rgba(0,0,0,.3);flex:none} .evcard .ev-chk:hover{color:var(--ink);border-color:rgba(91,168,255,.4)} .evcard .ev-chk.on{color:#9CC6FF;border-color:rgba(91,168,255,.55)} .evcard .ev-chk.on i{background:#5BA8FF;border-color:#5BA8FF;box-shadow:inset 0 0 0 2px rgba(0,0,0,.45)} .evcard .ev-how-help{flex-basis:100%;font-size:11px;color:var(--ink-3);line-height:1.5} .evcard .ev-how-help.warn{color:#E8C66B} .evcard .ev-save.attn{border-color:rgba(244,200,75,.55);color:#E8C66B;box-shadow:0 0 0 1px rgba(244,200,75,.18)} .evcard .ev-now{display:flex;align-items:center;gap:13px;flex-wrap:wrap} .evcard .ev-now-btn{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font:inherit;font-size:13px;font-weight:600; padding:10px 17px;border-radius:10px;border:1px solid rgba(91,168,255,.45); background:rgba(91,168,255,.13);color:#9CC6FF;transition:background .15s,border-color .15s,color .15s} .evcard .ev-now-btn:hover{background:rgba(91,168,255,.22);color:#fff} .evcard .ev-now-btn.active{border-color:rgba(63,204,139,.5);background:rgba(63,204,139,.14);color:#7FE3B4} .evcard .ev-now-btn.active:hover{background:rgba(63,204,139,.22);color:#fff} .evcard .ev-now-btn .spark{width:8px;height:8px;border-radius:50%;background:#3FCC8B;flex:none;animation:glcPulse 2s ease-out infinite} .evcard .ev-now-btn b{font-weight:700;text-decoration:underline;text-underline-offset:2px} .evcard .ev-now-hint{font-size:11px;color:var(--ink-3);flex:1;min-width:150px} @media(max-width:640px){ .evcard .ev-adjust{gap:10px} /* Mobil: laddsättet ska vara lika åtkomligt som på desktop — hela rader, 44 px träffytor, ingen horisontell scroll. */ .evcard .ev-how{gap:10px} .evcard .ev-how .seg{flex-basis:100%;gap:8px} .evcard .ev-how .seg button{flex:1;min-height:44px;font-size:12.5px} .evcard .ev-how label{flex:1 1 132px} .evcard .ev-how .inp{min-height:44px;width:100%} .evcard .ev-how .inp select{width:100%} .evcard .ev-how .inp input[type=time]{width:100%} .evcard .ev-chk{flex-basis:100%;min-height:44px} .evcard .ev-soc-row .dl{flex-basis:100%;margin-left:0} } `; document.head.appendChild(__evStyles); // LoadsPlanner (IA 2026-07-08) — planera skjutbara laster DIREKT på // "Bil & laster"-fliken (inte bara i Inställningar). Återanvänder settings- // sidans DeferrableLoadsCard (kräver settings-atoms.jsx + settings-ev.jsx + // settings.css inladdade i index.html) och sparar via // window.hems.patchSettings({deferrable_loads}). Backend planerar om inom // ~15 min → plan-banden uppdateras då. Lokalt state re-seedas ur settings // bara när det INTE finns osparade ändringar (stampar aldrig över redigering). const LoadsPlanner = ({ settings, onSaved }) => { const incoming = Array.isArray(settings?.deferrable_loads) ? settings.deferrable_loads : []; const sig = JSON.stringify(incoming); const [loads, setLoads] = React.useState(incoming); const [dirty, setDirty] = React.useState(false); const [saving, setSaving] = React.useState(false); const [toast, setToast] = React.useState(''); // '' | 'ok' | 'err' | 'invalid' | 'conflict' const [err422, setErr422] = React.useState({}); React.useEffect(() => { if (!dirty) setLoads(incoming); }, [sig]); // re-seed endast när rent const u = (_key, arr) => { setLoads(arr); setDirty(true); setToast(''); setErr422({}); }; /* Sparvägen är window.gcSaveSettings, INTE en egen GET+POST längre (2/9). Den här filen hade en tredje kopia av round-trippen, och den kopian fick aldrig de två sakerna de andra två vägarna fick: en spärr före anropet och en läsning av 422-body:n. Följden var att ett ogiltigt fönsterset eller en 0 kW-last på mobilen bara gav "Kunde inte spara — försök igen" — exakt felet backend rapporterade 2/9. gcSaveSettings har redan 409-omläsningen och bär status + body vidare på 422. */ const winErrs = loads.map(l => window.lwValidate ? window.lwValidate(window.lwFromLoad(l)) : {}); const badWin = winErrs.findIndex(e => Object.keys(e).length > 0); const badPwrLoad = loads.find(l => l && l.enabled !== false && !(Number(l.power_kw) > 0)); const blockMsg = badPwrLoad ? `Lasten "${badPwrLoad.name || 'utan namn'}" har ingen effekt — ange kW större än 0.` : (badWin >= 0 ? `${loads[badWin] && loads[badWin].name ? loads[badWin].name : 'En last'}: ${winErrs[badWin][Object.keys(winErrs[badWin])[0]]}` : null); const save = async () => { if (blockMsg) return; setSaving(true); setErr422({}); try { if (!window.gcSaveSettings) throw new Error('sparvägen saknas'); await window.gcSaveSettings((cur) => ({ ...cur, deferrable_loads: loads.map(l => { const rest = window.lwToPayload ? window.lwToPayload(l || {}) : { ...(l || {}) }; const id = rest.id; delete rest.id; return (id != null && id !== '') ? { id, ...rest } : rest; }), })); setDirty(false); setToast('ok'); if (onSaved) onSaved(); } catch (e) { if (e && e.status === 422 && window.lw422){ setErr422(window.lw422(e.body)); setToast('invalid'); } else setToast(e && /ändrades av någon annan/.test(e.message || '') ? 'conflict' : 'err'); } setSaving(false); }; /* Backendens fältfel i klartext. loc pekar alltid på deferrable_loads här, så lw422:s form (per last, per fönsterrad) räcker — ingen egen mappning. */ const errLines = Object.keys(err422).map(i => { const e = err422[i], name = (loads[i] && loads[i].name) || `Last ${Number(i) + 1}`; const rows = Object.keys(e.rows || {}).map(j => `fönster ${Number(j) + 1}: ${e.rows[j]}`); return [name + ': ' + (e.msg || ''), ...rows.map(r => name + ' — ' + r)] .filter(s => !/: $/.test(s)); }).reduce((a, b) => a.concat(b), []); if (!window.DeferrableLoadsCard) return null; return ( {errLines.length > 0 && (
{errLines.map((t, i) =>
{t}
)}
)}
{toast === 'ok' && Sparat — planeras inom ~15 min} {toast === 'err' && Kunde inte spara — försök igen} {toast === 'invalid' && Sparades inte — se felet ovan} {toast === 'conflict' && Ändrat på annat håll — läs in sidan igen} {blockMsg && {blockMsg}} {dirty && !toast && !blockMsg && Osparade ändringar}
); }; Object.assign(window, { EvCard, EvCards, buildEvLoadPlan, demoEvLoadPlan, LoadsPlanner, loadReasonText, evReasonText });