/* ============================================================================ GC · BIL — kollegans BilView i hans markup, mot vår sparväg ---------------------------------------------------------------------------- Hans kort, hans ordning: namn + switch, statusprick, SOC-läge, två reglage, laddläge, pausa batteriet, avresetid per veckodag i ett sjurutnät, ladda nu, laddplan. Plus laddhistoriken under. Sparvägen är OFÖRÄNDRAD — samma primitiv som EvCard använder sedan juni: · session (SOC, mål, tid) → window.hems.patchEvSession(session, chargerId) · inställningar → window.hems.saveSettingsPatch(buildPatch) buildPatch postar HELA ev_chargers-arrayen med samtliga fält på varje post. En partiell post nollställer fälten i backend (verifierat 2026-08-07): battery_kwh, name, solar_mode, target_pct, car_mode, default_car_id och departure_time. Rör inte den regeln. ETT undantag: `enabled` plockas medvetet bort ur posten — dött för listboxar, och backend bad uttryckligen att vi inte skickar det. Se buildPatch. Fyra avsteg från hans fil, alla för att kontraktet säger något annat: 1. Hans "Endast sol / HEMS bestämmer" är i backend SAMMA läge — solar_mode är true i båda. Avresedagen är strömbrytaren: utan dag aldrig nät, med dag nät som garanti (André 2026-08-07). Knapparna sätter därför dagen. 2. Hans laddplan räknas i klienten. Vår läses ur backendens plan, annars visar kortet en plan HEMS inte tänker köra. 3. Hans "solChargeMaxPrice" finns inte som fält. Utelämnad. 4. Batterikapacitet kommer ur bilprofilen, inte ur en tabell i koden. ========================================================================== */ /* Brytaren bor i ev_chargers[].active (live i staging 2026-08-16, rev 01380). Saknat eller true = aktiv, false = boxen planeras inte och får stop. Fältet HETER INTE enabled. `enabled` är dött för listboxar och bär ett gammalt stämplat false hos alla användare — vi läser det inte och skickar det inte. Namnbytet gjordes just för att den gamla inställningssidan spelar tillbaka hela laddarobjektet vid sparning; ett levande `enabled` hade släckts tyst av en gammal sparning. active skrivs EXPLICIT bara när användaren rört brytaren, annars utelämnas det — så en sparning aldrig stämplar ett värde användaren inte valt. */ const GB_DAYS = [ ['departure_mon','Mån','mon'], ['departure_tue','Tis','tue'], ['departure_wed','Ons','wed'], ['departure_thu','Tor','thu'], ['departure_fri','Fre','fri'], ['departure_sat','Lör','sat'], ['departure_sun','Sön','sun'], ]; const GB_JS_DAY = [1,2,3,4,5,6,0]; const gbHHMM = /^([01]\d|2[0-3]):[0-5]\d$/; const gbPad = (t) => { const m = /^(\d{1,2}):(\d{2})$/.exec(String(t||'').trim()); return m ? String(+m[1]).padStart(2,'0')+':'+m[2] : t; }; function GcBilCard({ c: cProp, i, day, tomorrow, nowIdx, settings, live, liveEvKw, evUnmet, evUnmetTotal, onSaved }){ /* Planeringsfönstret är två dygn, men det kommer som TVÅ arrayer — day (96) och tomorrow (96). Kortet fick bara day, så fönstret räknades till ett dygn: horisonten slutade vid midnatt, en avresa i morgon klassades som "utanför fönstret" och en verklig brist doldes för det vanligaste fallet (verifiering 25/8). qLbl och "i morgon" i planlistan var redan skrivna för 192 index — det var bara datan som saknades. */ const win = React.useMemo(() => { const t = Array.isArray(day) ? day : []; const m = Array.isArray(tomorrow) ? tomorrow : []; return t.concat(m); }, [day, tomorrow]); const winH = Math.round(win.length*15/60); /* Färsk sparning läggs över den hämtade konfigurationen. settings kan vara upp till två minuter gammal, och utan överlagringen byggs nästa draft ur ett dokument som inte känner den förra sparningen — då skriver veckoslingan null på dagen man just satt (Lennie 20/8: söndag försvann när lördag sparades). Samma fälla som ev-loads.jsx löste 2026-08-08. */ const [savedCh, setSavedCh] = React.useState(null); const c = React.useMemo(() => savedCh ? { ...cProp, ...savedCh } : cProp, [cProp, savedCh]); /* Släpp överlagringen när dokumentet hunnit ikapp — annars maskerar den ändringar gjorda på annat håll. */ React.useEffect(() => { if (!savedCh) return; if (Object.keys(savedCh).every((k) => (cProp[k] ?? null) === (savedCh[k] ?? null))) setSavedCh(null); }, [cProp, savedCh]); const chargerId = cProp.id; const name = (window.evModel && window.evModel.displayName(c, i)) || c.name || `Bil ${i+1}`; const session = window.evModel ? window.evModel.sessionFor(settings, c, i) : null; const car = window.evModel?.carFor ? window.evModel.carFor(settings, c, session, i) : null; const capKwh = Number(car?.battery_kwh) || Number(c.battery_kwh) || 60; const carList = Array.isArray(settings?.ev_cars) ? settings.ev_cars.filter(x => x && x.id != null) : []; const todayW = (new Date().getDay() + 6) % 7; /* INGEN dag förvald. Fältet låg tidigare på dagens dag, så första tiden man skrev landade på i dag även om man sedan valde en annan dag — två gröna rutor där användaren satt en (Lennie 20/8: torsdag OCH lördag 18:00). Ett tomt val kan inte skriva någonstans av misstag. */ const [selDay, setSelDay] = React.useState(null); const [draft, setDraft] = React.useState(null); const [toast, setToast] = React.useState(null); const [chargeNowOpt, setChargeNowOpt] = React.useState(null); /* Sanningen ur backend. draft finns bara medan användaren redigerar — utan den speglar kortet dokumentet, så en sparning aldrig kan visa gammalt läge (EV-kortets fel 2026-08-09: setDraft(null) mot en gammal kopia). */ const base = React.useMemo(() => { const w = {}; GB_DAYS.forEach(([k]) => { w[k] = c[k] || null; }); return { active: c.active !== false, cur: session?.current_pct ?? '', target: session?.target_pct ?? c.target_pct ?? 80, /* Dagen är läget: satt dag = "sol och billig el", ingen dag = endast sol. Legacy solar_mode:false visas som "sol och billig el" — närmast sitt nuvarande beteende — och migreras till true när användaren sparar. */ mode: (c.solar_mode === true && !c.departure_day && !GB_DAYS.some(([k]) => c[k])) ? 'sol' : 'hems', day: c.departure_day || '', time: session?.departure_time || c.departure_time || '', pause: settings?.ev_pause_battery === true, houseOnly: settings?.ev_house_only === true, prio: c.solar_priority || 'car_first', /* Vilken bil som står i boxen. Sessionen vinner över boxens förval — samma ordning som EvCard och som backend läser den. */ carId: session?.car_id ?? c.default_car_id ?? '', week: w, }; }, [c, session, settings]); const d = draft || base; const setD = (p) => setDraft({ ...d, ...p }); const dirty = !!draft; const setDay = (key, val) => setD({ week: { ...d.week, [key]: val || null } }); /* En stående plan i ett tryck. Poängen är att slippa planera per dag: sätt samma tid på alla dagar (eller vardagarna) en gång, och rör bara undantagen. Tiden tas ur den valda dagen, annars första satta, annars 07:00 — samma ordning som ev-week.jsx fillAll/weekdaysOnly. */ const fillWeek = (which) => { const first = (selKey && d.week[selKey]) || GB_DAYS.map(([k]) => d.week[k]).find(Boolean) || d.time || '07:00'; setD({ week: Object.fromEntries(GB_DAYS.map(([k], w) => [k, (which === 'alla' || w < 5) ? gbPad(first) : null])) }); }; const selKey = selDay == null ? null : GB_DAYS[selDay][0]; /* Vad tidsfältet skriver till: den valda raden. Utan val är fältet avstängt — det kan inte skriva någonstans av misstag. */ const timeKey = selKey; /* Per-tillfälle-tiden. ENDAST per box: toppnivå-`ev_session` läses inte av planeraren när hushållet har en laddarlista (backend 20/8), så en gammal tid där är död vikt och får inte varnas om. */ const evOverride = session?.departure_time ?? null; const anyWeek = GB_DAYS.some(([k]) => !!d.week[k]); const nextDl = React.useMemo(() => (anyWeek && window.ewNextDeadline) ? window.ewNextDeadline(d.week, new Date()) : null, [d.week, anyWeek]); /* Laddplanen ur backendens 48h-plan, samma serie som Schema och Avancerat ritar. Sammanhängande kvartar med samma källa slås ihop, som hans ranges. */ const plan = React.useMemo(() => { if (!win.length) return null; const out = []; let kwh = 0; for (let j = nowIdx; j < win.length; j++){ const e = win[j]?.ev?.[i]; if (!e || !(e.p > 0.05)) continue; kwh += e.p * 0.25; const last = out[out.length-1]; if (last && j === last.end && e.src === last.src) last.end = j + 1; else out.push({ start: j, end: j + 1, src: e.src }); } return { ranges: out, kwh }; }, [win, nowIdx, i]); const qLbl = (q) => String(Math.floor((q%96)/4)).padStart(2,'0') + ':' + String((q%4)*15).padStart(2,'0'); const srcCol = (s) => (window.GC_EV_SRC_COL && window.GC_EV_SRC_COL[s]) || '#60a5fa'; const nowKey = 'hems_ev_now_' + (chargerId != null && chargerId !== '' ? chargerId : i); const readLocalUntil = () => { try { const t = Number(localStorage.getItem(nowKey)); return Number.isFinite(t) && t > Date.now() ? t : null; } catch(e){ return null; } }; const [localUntil, setLocalUntil] = React.useState(readLocalUntil); const backendUntil = (() => { const v = session?.charge_now_until; if (!v) return null; const t = Date.parse(v); return Number.isFinite(t) ? t : null; })(); const untilTs = chargeNowOpt === 'off' ? null : chargeNowOpt != null ? chargeNowOpt : backendUntil != null ? backendUntil : localUntil; const nowActive = untilTs != null && untilTs > Date.now(); React.useEffect(() => { setChargeNowOpt(null); }, [session?.charge_now_until]); /* Backend är sanning när den svarar: ekar den en tidpunkt behövs inte den lokala kopian längre, och har fönstret löpt ut ska den bort. */ React.useEffect(() => { if (backendUntil != null || (localUntil != null && localUntil <= Date.now())){ try { localStorage.removeItem(nowKey); } catch(e){} setLocalUntil(null); } }, [backendUntil, localUntil, nowKey]); const evKw = liveEvKw != null ? liveEvKw : null; /* 50 W (backend 16/8). Skrevs för kW från början; med watt in blev tröskeln 0,2 W och kortet påstod "Laddar" på minsta mätbrus. */ const charging = evKw != null && evKw > 0.05; /* "Ladda nu" är ett ÖNSKEMÅL, inte ett tillstånd: fönstret kan vara aktivt medan boxen inte drar ström — för att målet är nått eller för att boxen inte hunnit svara. Kortet fick aldrig påstå laddning då. */ const atTarget = d.cur !== '' && Number(d.cur) >= Number(d.target); /* Faktisk laddning vinner alltid över inställningen. */ const chip = charging ? { txt:`Laddar ${window.gcNum(evKw,1)} kW`, col:'#4ade80' } : !d.active ? { txt:'Billaddning avstängd — boxen planeras inte', col:'#64748b' } : nowActive ? (atTarget ? { txt:`Ladda nu · klar — ${d.target} % nått`, col:'#4ade80' } : { txt:'Ladda nu begärt · väntar på boxen', col:'#38bdf8' }) : d.mode === 'sol' ? { txt:'Väntar på solöverskott', col:'#facc15' } : nextDl ? { txt:`Klar till ${GB_DAYS[nextDl.dayIdx][1].toLowerCase()} ${nextDl.time}`, col:'#4ade80' } : d.day ? { txt:`Klar till ${(GB_DAYS.find(([,,k])=>k===d.day)?.[1]||d.day).toLowerCase()} ${d.time||'—'}`, col:'#4ade80' } : { txt:'Ingen avresedag satt', col:'#fb923c' }; const dayMissing = d.mode === 'hems' && !d.day && !anyWeek; /* dayMissing BLOCKERAR INTE sparning. Det gjorde det tidigare, och då gick tillståndet inte att lämna: satte man en tid för att komma runt spärren kunde man aldrig ta bort den igen (Lennie 20/8). Ingen avresetid är ett giltigt val — bilen laddas då av solöverskott, aldrig från nätet — så texten är en upplysning om följden, inte ett fel. */ const save = async () => { let stash = null; const weekSet = GB_DAYS.some(([k]) => !!d.week[k]); /* ev_session.departure_time är en PER-TILLFÄLLE-override som byter tiden på nästa schemalagda dag. Nyckeln skickas ALLTID, med null när ingen tid ska gälla — utelämnad nyckel lämnade overriden orörd, så "Ingen avresetid" rensade rutnätet men inte dokumentet, och med rutnätet i bruk gick en gammal override aldrig att städa (en från 20 juni låg kvar). Är rutnätet i bruk är det rutnätet som gäller, alltså null. */ const wantSessTime = (!weekSet && d.mode === 'hems' && d.time) ? gbPad(d.time) : null; const sess = { current_pct: d.cur === '' ? null : Math.max(0, Math.min(100, Number(d.cur))), target_pct: Math.max(0, Math.min(100, Number(d.target))), /* Nyckeln skickas BARA som sträng. Samma validator som fällde laddarfälten på null (422 string_type) gäller rimligen här, och tömning görs ändå via settings-POST:en nedan — den enda väg som når både `ev_session` och `ev_sessions[ev_n]`. */ ...(wantSessTime ? { departure_time: wantSessTime } : {}), ...(d.carId ? { car_id: d.carId } : {}), }; /* Byggs mot det FÄRSKA dokumentet inuti saveSettingsPatch, och varje laddarpost bär samtliga sina fält — regeln från 2026-08-07. */ const buildPatch = (full) => { const p = {}; if ((d.pause === true) !== (settings?.ev_pause_battery === true)) p.ev_pause_battery = d.pause === true; if ((d.houseOnly === true) !== (settings?.ev_house_only === true)) p.ev_house_only = d.houseOnly === true; const list = Array.isArray(full?.ev_chargers) ? full.ev_chargers : null; if (!list) throw new Error('Laddarlistan saknas i inställningarna'); const idx = list.findIndex((x, k) => (x.id ?? `ev_${k+1}`) === chargerId || k === i); if (idx < 0) throw new Error('Hittar inte laddaren'); /* enabled plockas bort ur kopian. Full-array-regeln gäller i övrigt — varje annat fält måste spelas tillbaka — men just detta är dött för listboxar och backend bad uttryckligen att vi inte skickar det. Att eka tillbaka hela objektet var själva skälet till att fältet döptes om till active. */ const ch = { ...list[idx] }; delete ch.enabled; let touched = false; /* Full-array-regeln (7/8): varje post bär samtliga sina fält, med null där vi menar tomt. Skrivmodellen stämplar defaults för saknade fält, så utelämnande är inte samma sak som tomt — för departure_day och departure_mon..sun (Optional) är defaulten null, men för departure_time är den "07:00". Därför: null är rätt sätt att tömma de nullbara fälten, och departure_time rörs överhuvudtaget inte utan ett riktigt värde (den är `str`, kan inte tömmas, och öppnar aldrig en deadline ensam). */ const set = (k, v) => { const nv = (v === '' || v == null) ? null : v; if (String(ch[k] ?? '') !== String(nv ?? '')){ ch[k] = nv; touched = true; } }; if (ch.solar_mode !== true) { ch.solar_mode = true; touched = true; } /* Bara när användaren rört brytaren. `enabled` rörs aldrig. */ if (d.active !== (c.active !== false)) { ch.active = d.active; touched = true; } /* Veckorutnätet och det gamla ettdagsparet får inte gälla samtidigt. Kontraktet är tydligt: minst en satt dag ⇒ veckoschemat gäller, och paret departure_day/departure_time är bara fallback. Vi skrev båda, och då kan en dag användaren aldrig rört stå kvar som deadline (Lennie 20/8: fredag 18:00 dök upp när lördag sparades). Är rutnätet i bruk nollas paret, så det finns exakt en sanning. */ const weekSet = GB_DAYS.some(([k]) => !!d.week[k]); set('departure_day', (d.mode === 'sol' || weekSet) ? null : (d.day || null)); set('solar_priority', d.prio); /* Deadline finns bara om en dag är satt (resolvern: `if day or _weekly`). Tiden är en vilande standard — den skrivs när användaren angett en, och lämnas orörd annars. Att tömma DAGEN är det som betyder "aldrig nät". */ if (d.mode === 'hems' && !weekSet && d.time) set('departure_time', gbPad(d.time)); GB_DAYS.forEach(([k]) => set(k, d.mode === 'sol' ? null : (d.week[k] ? gbPad(d.week[k]) : null))); if (touched){ const arr = list.slice(); arr[idx] = ch; p.ev_chargers = arr; } /* Sessionsfälten städas INTE härifrån. Toppnivå-`ev_session` läses inte av planeraren när en laddarlista finns (backend 20/8) och ägs av PATCH-vägen; per-box-sessionen konsumeras av backend när avresan passerats (deadline ankras på set_at). */ /* Spara undan exakt det vi skrev — överlagringen ovan behöver det. Listan är FAST, inte Object.keys(ch): en tömd nyckel finns inte kvar i objektet och måste ändå speglas som tom i kortet. SÄTTS INTE här: mutatorn körs före POST:en och en andra gång vid 409, så state härifrån skulle cementera en sparning som aldrig gick igenom. */ if (touched) stash = Object.fromEntries( ['active','solar_mode','solar_priority','departure_day','departure_time', ...GB_DAYS.map(([k]) => k)].map((k) => [k, ch[k] ?? null])); return p; }; try { setToast({ kind:'info', msg:'Sparar…' }); /* Sessionen PATCH:as bara när något i den faktiskt ändrats. Att skicka den vid varje sparning gav ett extra anrop som kunde fela på egna meriter — en ren "ingen avresetid", som bara rör laddarens fält, ska inte kunna stoppas av sessionsendpointen (Lennie 20/8). */ const sessDirty = String(d.cur) !== String(base.cur) || Number(d.target) !== Number(base.target) || String(d.carId || '') !== String(base.carId || '') || (!!wantSessTime && gbPad(evOverride || '') !== wantSessTime); if (sessDirty) await window.hems.patchEvSession(sess, chargerId); await window.hems.saveSettingsPatch(buildPatch); if (stash) setSavedCh(stash); setDraft(null); setToast({ kind:'ok', msg:'Sparat' }); onSaved && onSaved(); } catch(e){ /* Visa backendens egna ord. Den tidigare texten sa alltid "Kunde inte spara", vilket gömde både 401, 422 och nätglapp bakom samma mening. */ const m = String(e?.message || e); setToast({ kind:'err', msg: /409|ändrad|ändrades/i.test(m) ? 'Ändrat på annat håll — läs in sidan igen' : 'Kunde inte spara · ' + m.replace(/^HEMS /,'').slice(0, 90) }); setTimeout(() => setToast(null), 12000); /* Sant/falskt bubblar till Ladda nu-vägen: en misslyckad sparning MÅSTE stoppa laddstarten — annars startar den med gamla inställningarna. */ return false; } setTimeout(() => setToast(null), 12000); return true; }; const sessionBase = () => ({ current_pct: d.cur === '' ? null : Number(d.cur), target_pct: Number(d.target), ...(session?.car_id ? { car_id: session.car_id } : {}), }); const nowStart = async () => { /* Osparade knappval (pausa/täck huset, laddläge, soltröskel) SPARAS först — annars startar laddningen med gamla inställningarna (André gick själv på det 31/8: sessionen PATCH:as men inställningarna ligger kvar osparade). Misslyckas sparningen avbryts starten HELT, före varje lokal ändring — aldrig en start med fel inställningar. Samma regel i nowCancel. */ if (dirty && !(await save())) return; const until = Date.now() + 360*60000; setChargeNowOpt(until); setLocalUntil(until); try { localStorage.setItem(nowKey, String(until)); } catch(e){} try { await window.hems.patchEvSession({ ...sessionBase(), charge_now_minutes: 360 }, chargerId); } catch(e){ setChargeNowOpt(null); setToast({ kind:'err', msg:'Kunde inte starta' }); setTimeout(()=>setToast(null),6000); } }; const nowCancel = async () => { if (dirty && !(await save())) return; setChargeNowOpt('off'); setLocalUntil(null); try { localStorage.removeItem(nowKey); } catch(e){} try { await window.hems.patchEvSession({ ...sessionBase(), charge_now_minutes: 0 }, chargerId); } catch(e){ setChargeNowOpt(null); setToast({ kind:'err', msg:'Kunde inte avbryta' }); setTimeout(()=>setToast(null),6000); } }; const need = Math.max(0, ((Number(d.target) - Number(d.cur || 0)) / 100) * capKwh); /* INGEN egen reachPct här. Att räkna cur + plan.kwh/cap*100 och varna när den ligger under målet var precis felet: planen är det backend hunnit lägga ut. Bristbeskedet kommer ur unmetKwh nedan. */ /* Ligger avresan UTANFÖR planeringsfönstret (win.length kvartar, winH h) är planen med nödvändighet en delplan och säger ingenting om utfallet. André 25/8: söndag 15:00 satt på en tisdag gav "2,2 kWh planerat · når 53 % av 100 %", läst som att HEMS vägrade nå målet, medan backend sa unmet: 0. Deadlinen löses ur veckorutnätet när det är i bruk, annars ur dag + tid. */ const dlAt = React.useMemo(() => { if (nextDl) return nextDl.at; if (d.mode === 'sol' || !d.day || !d.time) return null; const gi = GB_DAYS.findIndex(([,,k]) => k === d.day); if (gi < 0) return null; const m = /^(\d{1,2}):(\d{2})$/.exec(gbPad(d.time) || ''); if (!m) return null; const base = new Date(); const at = new Date(base); at.setHours(Number(m[1]), Number(m[2]), 0, 0); let add = (GB_JS_DAY[gi] - at.getDay() + 7) % 7; if (add === 0 && at <= base) add = 7; at.setDate(at.getDate() + add); return at; }, [nextDl, d.mode, d.day, d.time]); const horizonEnd = React.useMemo(() => { if (!win.length) return null; const t = new Date(); t.setHours(0,0,0,0); return new Date(t.getTime() + win.length*15*60000); }, [win.length]); const dlBeyond = !!(dlAt && horizonEnd && dlAt > horizonEnd); /* BACKEND ÄGER FRÅGAN OM MÅLET NÅS. ev_per_car[].unmet är dess besked, i kWh. Kortet räknade tidigare en egen procent ur planen och varnade så snart den låg under målet — men planen är det backend hunnit lägga ut, inte allt den tänker lägga ut. André 25/8: avresa ons 18:00, plan 27,6 kWh tis 11:00–13:15, kortet sa "når 87 % av 100 %" medan backend sa unmet: 0. Resten planeras i en senare cykel; varningen var ett falskt larm om ett verkligt problem. ev-loads.jsx gjorde redan rätt (`reachPct = unmetKwh > 0 ? … : null`) — samma divergens som avresetiden hade. Ingen unmet-uppgift alls (äldre backend) → ingen varning: hellre tyst än falsk. */ const unmetKwh = React.useMemo(() => { const list = Array.isArray(evUnmet) ? evUnmet : null; if (list){ const cid = 'ev_' + (i + 1); const hit = list.find(x => String(x?.charger_id ?? '') === cid) || (list.length === 1 ? list[0] : null) || list[i]; if (hit && hit.unmet != null) return Number(hit.unmet) || 0; } /* Aggregatet duger bara med EN box — med flera går det inte att tillskriva någon enskild. */ if (evUnmetTotal != null && (!list || list.length <= 1)) return Number(evUnmetTotal) || 0; return null; }, [evUnmet, evUnmetTotal, i]); /* Brist = backendens unmet > 0 OCH deadlinen inne i fönstret. Procenten räknas ur unmet, inte ur planen: det är bristens egen storlek. */ const shortfall = !dlBeyond && unmetKwh != null && unmetKwh > 0.05; const missPct = shortfall && capKwh > 0 ? Math.max(0, Math.round(Number(d.target) - (unmetKwh / capKwh) * 100)) : null; return (
⚡ {name}
{chip.txt}
{d.cur === '' ? '—' : d.cur} % {d.target} % {/* Ingen upprepning av statusraden: när chipen ovanför redan säger "Klar till mån 18:00" (eller "Ingen avresedag satt") sa den här underraden samma sak 30 px längre ner — dubblering på kortets minsta yta. Raden visas bara när chipen är upptagen med något annat (pågående laddning, väntar på boxen), då deadlinen annars inte syns någonstans. */} {!/^(Klar till|Ingen avresedag)/.test(chip.txt) && ( {d.mode === 'sol' ? 'ingen avresetid' : nextDl ? <>klar till {GB_DAYS[nextDl.dayIdx][1].toLowerCase()} {nextDl.time} : d.day ? <>klar till {(GB_DAYS.find(([,,k])=>k===d.day)?.[1]||d.day).toLowerCase()} {d.time||'—'} : 'ingen avresedag'} )}
Nuvarande laddning {d.cur === '' ? '—' : d.cur} %
setD({ cur: Number(e.target.value) })} className="w-full accent-sky-400"/>
Mål {d.target} %
setD({ target: Number(e.target.value) })} className="w-full accent-emerald-400"/>
{/* Bilvalet visas bara när det finns mer än en bil att förväxla den med. Boxens car_mode styr vilken som är FÖRVALD, inte om valet finns — en box laddar en bil i taget, och det är här laddningen planeras (regeln från 2026-07-30). */} {carList.length > 1 && (
Vilken bil står här nu?
{carList.map((car) => ( ))}
)}
Laddläge
{[{ id:'sol', txt:'☀ Endast sol', desc:'aldrig från elnätet' }, { id:'hems', txt:'⚡ Sol och billig el', desc:'nät som garanti före avresa' }].map((m) => ( ))}
{d.mode === 'sol' && (
Bilen laddas enbart ur solöverskott, aldrig från elnätet — ingen avresetid gäller, och utan sol nås inte målet.
)} {/* TVÅ val (backend-kontrakt 30/8, ersätter klasstext-varianten från build 158): ev_pause_battery + NYA ev_house_only (bool, default false). Knapp 2 visas bara när knapp 1 är på — utan paus finns inget "täck huset"-val, och en död toggel vore samma fälla som säljknappen i build 115. Klassfältet visas inte längre (kvar i GET, ska inte redigeras). Instruktionen är ALLTID synlig: den förklarar varför "rekommenderad" och "uppmätt" effekt skiljer sig i egen- förbrukningsläge (Magnus 30/8: begärt 0,8 kW, uppmätt −1,4; Anders 29/8: begärt 1,8, uppmätt −10…−13 in i bilen). Texterna ordagrant från backend. */}
Batteriet när bilen laddar
{/* Varningen hör till knapp 2 (André 30/8): det är "täck huset"-valet som ber växelriktaren ladda ur under pågående billaddning, och där egenförbrukningsläget gör tvärtom. Röd, och visas bara med knappen — under knapp 1 ensam gäller pausen och kravet är inte aktuellt. */} {d.pause && ( {/* 5-minutersnotisen (backend 1/9): förklarar varför täckningen inte är sömlös INNAN någon felanmäler den. Grå kontext, inte del av röda kravet — den beskriver normalt beteende. */}
Kommandon och effekt skickas var 5:e minut — mellan uppdateringarna kan batteriet inte täcka lasten helt sömlöst. Korta avvikelser är normala.
Kräver att växelriktaren laddar ur med den effekt HEMS rekommenderar (visas under Rekommenderad effekt) — inte i egenförbrukningsläge. I egenförbrukningsläge täcker batteriet all last, även bilen.
)} {/* Allmän not (backend 30/8): dämpad, under båda — gäller BÅDA valen och är kontext, inte larm; larmet (rött) hör till knapp 2. */}
Båda valen förutsätter att växelriktaren följer HEMS kommandon. I egenförbrukningsläge styr växelriktaren själv — då gäller inte inställningarna här.
Solöverskottet går till
{[['car_first','Bilen först'],['auto','HEMS väljer'],['battery_first','Batteriet först']].map(([id,txt]) => ( ))}
{/* Avresetid per veckodag — hans sjurutnät. Sju valfria fält per laddbox; saknad nyckel = ingen avresa den dagen. I sol-läge finns ingen deadline alls, så rutnätet döljs. */} {d.mode === 'hems' && (
Avresetid per dag
{[['alla','Alla dagar'],['vardag','Vardagar']].map(([id,txt]) => ( ))}
{/* Sju dagar, två oberoende tillstånd — och det var felet: markeringen betydde "den här dagen redigeras", men lästes som "den här dagen är vald". Nu bär SATT TID den starka signalen (ifylld ruta, tiden i klartext) och redigeringen bara en ram. */}
{GB_DAYS.map(([key, lbl], w) => { /* Grön = dagen har en egen tid. Trycket väljer vilken rad som redigeras — varje dag planeras och rensas för sig. ANDRA trycket på en satt, redan vald dag TÖMMER den: "Rensa alla dagar" var enda synliga vägen att ta bort en dag (André 25/8), och en enskild dag ska gå att ta bort där den står. Två steg, inte ett: ett ensamt tryck på en satt dag väljer den för redigering, så en felträff aldrig raderar en tid. */ const set = !!d.week[key], on = selDay === w; const clears = set && on; return ( ); })}
{selKey ? GB_DAYS[selDay][1] : 'Dag'} selKey && setDay(selKey, e.target.value)} className={`min-w-0 flex-1 rounded-lg border px-3 py-1.5 text-sm font-bold ${timeKey ? 'border-slate-700 bg-slate-800/70 text-slate-100' : 'border-slate-800 bg-slate-900/40 text-slate-600'}`} style={{ ...window.GC_MONO, colorScheme:'dark' }}/> {/* ETT tryck rensar den valda dagen. Rutans andra tryck gör samma sak, men får inte vara enda vägen: på iOS är två tryck på samma lilla ruta en zoom-gest. IKONBRED (~34 px) — texten "Ta bort tor" mätte 82 px och tryckte "Rensa alla dagar" till två rader vid 360 px. */} {selKey && d.week[selKey] && ( )} {/* Knappen gör vad den heter: ingen avresetid överhuvudtaget. Den rensade tidigare bara den redigerade dagen, vilket inte står någonstans i etiketten (Lennie 20/8). Det gamla ettdagsparet måste med — annars tar sparningen paret som deadline så fort rutnätet är tomt, och "ingen avresetid" hade betytt motsatsen. */}
{!timeKey && (
Tryck en dag och sätt dess tid — eller ”Alla dagar” för en stående plan som gäller tills du ändrar den.
)} {/* Borttagningen förklaras där den utförs, inte i en hjälptext man måste hitta först. Visas bara när den valda dagen har en tid. */} {selKey && d.week[selKey] && (
Tryck ✕ (eller {GB_DAYS[selDay][1].toLowerCase()} igen) för att ta bort bara den dagen — övriga ligger kvar.
)} {/* Overriden är osynlig annars, och då ser dagvalet ut som att det slutat fungera: kortet säger "klar till lör 07:00" medan backend kör engångstiden. ev-week.jsx visar den av precis samma skäl. Raden är ett TILLSTÅND, inte ett löfte: städningen försöker vid sparning, och lyckas den försvinner raden av sig själv. Kräver en AKTIV deadline-källa: utan veckodag och utan departure_day finns ingen avresa att byta tid på — då är sessionens tid inert och dayMissing-raden hela sanningen. */} {evOverride && d.mode === 'hems' && (anyWeek || (d.day && gbPad(evOverride) !== gbPad(d.time || ''))) && (
En engångstid ligger kvar i HEMS ({evOverride}) och byter tiden på nästa avresa.
)} {dayMissing && (
Ingen avresedag satt. Bilen laddas då bara av solöverskott, aldrig från nätet.
)}
)}
Laddplan{d.mode === 'sol' ? ' · endast sol' : ' · billigast före avresa'}
{need < 0.1 ? (
Ingen laddning behövs — målet är redan uppnått.
) : !plan || !plan.ranges.length ? (
{dlBeyond ? `Ingen laddning planerad än — avresan ligger längre bort än planeringsfönstret (${winH} timmar).` : `Ingen laddning planerad inom ${winH} timmar.`}
) : (
{plan.ranges.map((r, k) => (
{qLbl(r.start)}–{qLbl(r.end)}{r.start >= 96 ? ' i morgon' : ''} {r.src}
))}
{window.gcNum(plan.kwh,1)} kWh planerat{plan.ranges.length > 1 ? ' totalt' : ''} {shortfall && missPct != null && ( · når {missPct} % av {d.target} % )} {dlBeyond && · så långt fönstret räcker}
{/* Planen är backendens, inte kortets: den räknas om i nästa cykel efter en ändring. Utan den här raden läses ett för lågt tal som att HEMS vägrar nå målet (Lennie 20/8). */} {shortfall && (
Planen är den HEMS senast räknade fram. Ändrar du avresetid eller mål räknas den om inom några minuter — talet ovan gäller tills dess.
)} {dlBeyond && (
Avresan ligger längre bort än planeringsfönstret ({winH} timmar), så det här är bara början av planen. HEMS fyller på när avresan kommer närmare.
)}
)}
{(dirty || toast) && (
{dirty && } {toast && {toast.msg}}
)}
); } function GcBil({ day, tomorrow, nowIdx, settings, live, evWList, evUnmet, evUnmetTotal, onSaved }){ if (!settings) return
Hämtar laddboxar…
; const chargers = window.evModel?.normalize ? window.evModel.normalize(settings) : []; if (!chargers.length) return
Ingen laddbox konfigurerad.
; return (
{chargers.map((c, i) => { /* Både evWList och live.ev_power är WATT — fältnamnen i payloaden bär suffixet _w. Adaptern delar med 1000 på sin sida; här görs samma sak en enda gång, vid källan, så kortet nedan bara känner kW. */ let liveEvKw; if (Array.isArray(evWList) && evWList.length > i) liveEvKw = (Number(evWList[i]) || 0) / 1000; else if (i === 0 && live && live.ev_power != null) liveEvKw = (Number(live.ev_power) || 0) / 1000; return ; })} {window.GcLaddhistorik && }
); } Object.assign(window, { GcBil, GcBilCard });