/* ============================================================================ 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 (