/* ============================================================================ 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. Tredje läget, Billigast från nätet, är solar_mode: false (André 4/10). 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; }; /* Engångstidens avresa (backend 4/10): första schemalagda dag där dagens datum med tiden ligger strikt efter ankaret (set_at, eller nu för en ny tid). Är tiden passerad samma dag gäller nästa schemalagda dag. keys = veckodagarna som gäller: veckoschemat, annars avresedagen, annars alla dagar. */ function gbOvrAt(keys, hhmm, anchor){ const m = /^(\d{1,2}):(\d{2})$/.exec(gbPad(hhmm) || ''); if (!m || !keys || !keys.length || !(anchor instanceof Date) || isNaN(anchor)) return null; for (let k = 0; k <= 7; k++){ const at = new Date(anchor); at.setDate(at.getDate() + k); at.setHours(+m[1], +m[2], 0, 0); const w = (at.getDay() + 6) % 7; if (keys.includes(GB_DAYS[w][2]) && at > anchor) return { at, dayIdx: w }; } return null; } const gbDayKeys = (week, day) => { const wk = GB_DAYS.filter(([k]) => week && week[k]).map(([,,k]) => k); return wk.length ? wk : day ? [day] : GB_DAYS.map(([,,k]) => k); }; function GcBilCard({ c: cProp, i, day, tomorrow, nowIdx, settings, live, liveEvKw, evUnmet, evUnmetTotal, unmanaged, 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]); /* Identitetsläget (TILL_DESIGN_laddbox_bil_identitet 3/10, D2): kortet skriver och minns sin box på uid, aldrig på platsen i listan. Utan identitet: det gamla id:t som förut. */ const ident = !!(window.evModel?.identityMode && window.evModel.identityMode(settings)); const chargerId = window.evModel?.chargerKey ? window.evModel.chargerKey(settings, cProp) : 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; /* D10: "Fråga varje gång" utan vald bil planeras inte — kortet säger Välj bil och erbjuder valet. D1: ingen session alls = boxens förval och texten "ingen laddning registrerad". */ const needCar = !!(window.evModel?.needsCarChoice && window.evModel.needsCarChoice(settings, c, session)); const noSession = ident && !session; 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); /* v766 (förenklad flik): vilket tal stapeln ändrar, bilvalet, "Ändra" vid avresan och den öppna inställningsraden. */ const [edit, setEdit] = React.useState('cur'); const [carOpen, setCarOpen] = React.useState(false); const [depOpen, setDepOpen] = React.useState(false); const [openRow, setOpenRow] = React.useState(null); /* Engångstiden (ev_session.departure_time) byter TIDEN på nästa schemalagda dag och ankras på set_at: efter passerad avresa är den förbrukad (DESIGN_STATE, "Overriden konsumeras"). Aktiv = veckoschemat används och nästa avresa räknad från set_at ligger framåt. Utan set_at visas ingen engångstid. Backend 4/10: avresan är första schemalagda dag med engångstiden strikt efter set_at (gbOvrAt). Utan veckoschema räknas samma regel med avresedagen, annars varje dag. Endast sol har ingen avresa. */ const ovrBase = React.useMemo(() => { const t = session?.departure_time; if (!t) return ''; const wk = {}; GB_DAYS.forEach(([k]) => { wk[k] = c[k] || null; }); if (c.solar_mode === true && !c.departure_day && !GB_DAYS.some(([k]) => wk[k])) return ''; const ms = window.evModel?.tsOf ? window.evModel.tsOf(session?.set_at) : Date.parse(session?.set_at); if (!isFinite(ms)) return ''; const dl = gbOvrAt(gbDayKeys(wk, c.departure_day), t, new Date(ms)); return dl && dl.at.getTime() > Date.now() ? gbPad(t) : ''; }, [session, c]); /* 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, /* Läget härleds (TILL_DESIGN_laddlage_av_bevaras 4/10, K3): solar_mode inte true = Billigast från nätet ('grid'), true utan dag och veckodag = Endast sol, true med dag eller veckodag = Sol och billig el. Utan dag är Sol och billig el spärrad (dayMissing nedan). */ mode: c.solar_mode !== true ? 'grid' : (!c.departure_day && !GB_DAYS.some(([k]) => c[k])) ? 'sol' : 'hems', day: c.departure_day || '', time: (!GB_DAYS.some(([k]) => c[k]) && ovrBase) || 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: needCar ? '' : (session?.car_id ?? c.default_car_id ?? ''), /* v766: tiden för nästa resa (engångstiden) när veckoschemat används. Tomt = schemats tid. */ nextTime: GB_DAYS.some(([k]) => c[k]) ? ovrBase : '', /* Den aktiva engångstiden som sessionen bär nu, med eller utan veckoschema (backend 4/10). */ sessT: ovrBase, week: w, }; }, [c, session, settings, ovrBase]); const d = draft || base; const setD = (p) => setDraft({ ...d, ...p }); const dirty = !!draft; /* Tiden sessionen ska bära (backend 4/10). Veckoschema: engångstiden om den finns. Utan veckoschema: tiden när kunden ändrat den, annars den aktiva engångstiden; ingen alls betyder att boxens avresetid gäller. Endast sol har ingen avresa. */ const sessTimeOf = (dd) => { if (dd.mode === 'sol') return null; const t = GB_DAYS.some(([k]) => !!dd.week[k]) ? dd.nextTime : (dd.time && gbPad(dd.time) !== gbPad(base.time || '')) ? dd.time : (base.sessT && gbPad(dd.time || '') === base.sessT) ? dd.time : ''; return t && gbHHMM.test(gbPad(t)) ? gbPad(t) : null; }; 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; 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]); /* Engångstidens avresa med veckoschema (backend 4/10), räknad från nu. Så länge avresan ligger framåt ger det samma dag som från set_at, och en ny tid sparas med set_at = nu. Kan ligga på en annan dag än schemats nästa avresa: i dag gäller om i dag är schemalagd och tiden inte passerat. */ const ovr = anyWeek && d.mode !== 'sol' && d.nextTime ? gbPad(d.nextTime) : ''; const ovrDl = React.useMemo(() => ovr ? gbOvrAt(gbDayKeys(d.week, null), ovr, new Date()) : null, [ovr, d.week]); /* 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' } : needCar && !d.carId ? { txt:'Välj bil', col:'#fbbf24' } : 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' } /* v766: "Klar till" står i avreseraden, så statusraden säger vad planen gör härnäst. */ : atTarget ? { txt:'Målet är nått', col:'#4ade80' } : (d.mode === 'hems' && !nextDl && !d.day) ? { txt:'Ingen avresedag satt', col:'#fb923c' } : plan && plan.ranges.length ? (plan.ranges[0].start <= nowIdx ? { txt:'Laddning planerad nu', col:'#4ade80' } : { txt:`Nästa laddning ${plan.ranges[0].start >= 96 ? 'i morgon ' : ''}${qLbl(plan.ranges[0].start)}`, col:'#4ade80' }) : { txt:'Ingen laddning planerad', col:'#94a3b8' }; const dayMissing = d.mode === 'hems' && !d.day && !anyWeek; /* dayMissing SPÄRRAR sparningen, bara i Sol och billig el (André 27/9: spärr, ingen förifyllning; K5 4/10). Sol och billig el är solar_mode: true med minst en avresedag, så utan dag blev läget Endast sol vid nästa läsning och kortet hoppade dit. Billigast från nätet sparas utan dag: avresetiden räcker. Vägen ut finns: ett annat laddläge sparas alltid och Ångra släpper utkastet. save() vägrar själv, inte bara knappen, eftersom nowStart/nowCancel anropar save() direkt vid osparat utkast. Inga dagar fylls i och läget byts aldrig automatiskt. */ const blockTxt = 'ändringarna sparas inte utan avresedag. Lägg till en dag i veckoschemat, välj ett annat laddläge eller tryck Ångra.'; const save = async (blockedMsg) => { if (dayMissing) { setToast({ kind:'warn', msg: typeof blockedMsg === 'string' ? blockedMsg : 'Inte sparat: ' + blockTxt }); setTimeout(() => setToast(null), 8000); return false; } 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. */ /* PATCH {ev_session} ersätter hela boxens session (backend 4/10): bara skickade fält finns kvar. Den aktiva engångstiden och bilen skickas därför i varje sessionsanrop, och en utelämnad tid tar bort engångstiden. Använd schemat gör just det. */ const wantSessTime = sessTimeOf(d); /* Boxens departure_time (vilande standard) skrivs bara när kunden ändrat tiden (4/10). Förifyllnaden tar sessionens tid före boxens, så en sparning utan tidsändring skrev annars över standarden med engångstiden. */ const timeTouched = !!d.time && gbPad(d.time) !== gbPad(base.time || ''); 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 (null ger 422 string_type). Utelämnad tar bort engångstiden. */ ...(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'); /* Identitetsläget (D2): bara kortets egen box, på uid. Annars dagens matchning. */ const idx = (window.evModel?.identityMode && window.evModel.identityMode(full)) ? window.evModel.findBox(list, full, cProp) : 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; } }; /* K1 (4/10): solar_mode skrivs bara när kunden valt laddläge i utkastet (modeSet): Endast sol och Sol och billig el = true, Billigast från nätet = false. Annars följer värdet med oförändrat ur det färska dokumentet. Raden som satte true vid varje sparning (sedan 16/8) slog på Ladda med sol hos boxar som stod på Av. */ if (d.modeSet){ const sm = d.mode !== 'grid'; if (ch.solar_mode !== sm){ ch.solar_mode = sm; 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); /* I Sol och billig el finns deadline bara om en dag är satt (resolvern: `if day or _weekly`); i Billigast från nätet räcker tiden. Tiden är en vilande standard — den skrivs när användaren ändrat den, och lämnas orörd annars. Att tömma DAGEN är det som betyder "aldrig nät" i solläget. */ if (d.mode !== 'sol' && !weekSet && timeTouched) 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 || '') || String(wantSessTime || '') !== String(base.sessT || ''); const pr = sessDirty ? await window.hems.patchEvSession(sess, chargerId) : null; await window.hems.saveSettingsPatch(buildPatch, 'bilkort'); if (stash) setSavedCh(stash); setDraft(null); const ended = await endedText(pr); setToast({ kind:'ok', msg: ended ? 'Sparat. ' + ended : '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); /* Serverns felsvar med färdig text (detail.message) visas som de står. Spärren controller_box_list_locked finns inte längre (backend 4/10). */ setToast(e && e.serverText ? { kind:'err', msg: m } : { 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; }; /* Ladda nu och Avbryt ersätter också hela sessionen: engångstiden och bilen följer med (backend 4/10). Efter en sparning i samma tryck är det utkastets bil, annars sessionens. */ const sessionBase = () => { const t = sessTimeOf(d), car = (dirty && d.carId) || session?.car_id || null; return { current_pct: d.cur === '' ? null : Number(d.cur), target_pct: Number(d.target), ...(t ? { departure_time: t } : {}), ...(car ? { car_id: car } : {}), }; }; /* D6: servern avslutar en annan box laddning med samma bil och säger vilka i ended_sessions. Boxens namn tas ur en omläsning av inställningarna. */ const endedText = async (r) => { const keys = r && Array.isArray(r.ended_sessions) ? r.ended_sessions : null; if (!keys || !keys.length || !window.evModel?.endedNote) return null; let fresh = null; try { fresh = await window.hems.getSettings(); } catch(_){} if (fresh && fresh.__fromMock) fresh = null; const nm = (carList.find(x => String(x.id) === String(d.carId)) || car || {}).name || null; return window.evModel.endedNote(fresh, settings, keys, nm); }; 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('Ladda nu startar inte: ' + blockTxt))) return; const until = Date.now() + 360*60000; setChargeNowOpt(until); setLocalUntil(until); try { localStorage.setItem(nowKey, String(until)); } catch(e){} try { const pr = await window.hems.patchEvSession({ ...sessionBase(), charge_now_minutes: 360 }, chargerId); const ended = await endedText(pr); if (ended){ setToast({ kind:'ok', msg: ended }); setTimeout(()=>setToast(null),12000); } } catch(e){ setChargeNowOpt(null); setToast({ kind:'err', msg: e && e.serverText ? e.message : 'Kunde inte starta' }); setTimeout(()=>setToast(null),6000); } }; const nowCancel = async () => { if (dirty && !(await save('Ladda nu avbryts inte: ' + blockTxt))) 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: e && e.serverText ? e.message : '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 (ovrDl) return ovrDl.at; 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; }, [ovrDl, 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){ /* Identitetsläget (D4): bara boxens slot, ingen position. */ const hit = ident ? (cProp.slot ? list.find(x => String(x?.charger_id ?? '') === String(cProp.slot)) : null) : (list.find(x => String(x?.charger_id ?? '') === 'ev_' + (i + 1)) || (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, ident, cProp.slot]); /* 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; /* v766 (förenklad Bil-flik, mockupen Bil-forenklad.html; André 3/10 lät mig bestämma). Ordning: bil och status, en stapel för nu och mål, Klar till med tid för nästa resa, veckoschema som rader, laddplan på tidslinje med Ladda nu, inställningar ihopfällda med förklaringarna i den öppnade raden, Spara/Ångra i en rad ovanför flikarna. Sparvägen och reglerna ovan är oförändrade. */ const { GBU_I, GbuIcon, GbuSwitch, GbuBattery, GbuValTab, GbuTimeline, GbuSetRow, GbuToggleRow, GbuSaveBar } = window; const ctAp = window.HemsCT ? window.HemsCT.approvalFor(settings, c) : null; /* Laddboxtestets märkning (CtMark) som etikett på den hopfällda raden: ej testad eller fungerar inte. */ const markOf = (k) => { if (!ctAp) return null; const m = (k === 'pause' ? ctAp.pause_mark : ctAp.house_only_mark) || 'not_tested'; return m === 'ok' ? null : m; }; const marks = [markOf('pause'), d.pause && d.houseOnly ? markOf('house_only') : null]; const batTag = marks.includes('fail') ? 'Fungerar inte' : marks.some(Boolean) ? 'Ej testad' : null; const carHere = needCar && !d.carId ? null : (carList.find(x => String(x.id) === String(d.carId)) || car || null); const pickCars = carList.length > 1 || (needCar && carList.length > 0); const showCars = pickCars && (carOpen || (needCar && !d.carId)); const val = edit === 'cur' ? (d.cur === '' ? 0 : Number(d.cur)) : Number(d.target); const setVal = (v) => setD(edit === 'cur' ? { cur: v } : { target: v }); const stepVal = (s) => setVal(Math.max(0, Math.min(100, val + s))); const LONG = ['måndag','tisdag','onsdag','torsdag','fredag','lördag','söndag']; const depDayIdx = ovrDl ? ovrDl.dayIdx : nextDl ? nextDl.dayIdx : (d.day ? GB_DAYS.findIndex(([,,k]) => k === d.day) : -1); const depTime = ovrDl ? ovr : nextDl ? nextDl.time : (d.time ? gbPad(d.time) : '—'); /* Schemats tid den dag engångstiden gäller, för raden "Bara den här gången". */ const schedT = ovrDl ? gbPad(d.week[GB_DAYS[ovrDl.dayIdx][0]] || '') : ''; const ovrOn = !!ovrDl && ovr !== schedT; /* Avresans kvart i planeringsfönstret (dag 0 = i dag) för markeringen på tidslinjen. */ const t0 = new Date(); t0.setHours(0, 0, 0, 0); const depQ = dlAt ? Math.floor((dlAt.getTime() - t0.getTime()) / 900000) : null; const toggleRow = (id) => setOpenRow(o => o === id ? null : id); const prioTxt = { car_first:'Bilen först', auto:'HEMS väljer', battery_first:'Batteriet först' }; const MODE_TXT = { hems:'Sol och billig el', sol:'Endast sol', grid:'Billigast från nätet' }; const cardCls = 'rounded-2xl border border-slate-800 bg-slate-900/40 px-4 py-4'; const h2Cls = 'text-[15px] font-semibold text-slate-100'; const dim = d.active ? '' : ' pointer-events-none opacity-40'; const openMode = () => { setOpenRow('mode'); setTimeout(() => { const el = document.getElementById('gb-set-' + i); if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 120, behavior:'smooth' }); }, 30); }; return (
Dra i stapeln för att ändra {edit === 'cur' ? 'nuvarande laddning' : 'målet'}
{noSession &&Ingen laddning registrerad
}Byter tiden på {LONG[depDayIdx]}, inte dagen. Veckoschemat ändras inte.
} {ovr && nextDl && ( )}Bilen är klar nästa gång klockan är så mycket. Fasta avresedagar lägger du i veckoschemat nedan.
Avresetid per dag. Tiderna gäller varje vecka tills du ändrar dem.{d.mode === 'grid' ? ' Valfritt: utan dagar gäller tiden under Klar till.' : ''}
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.`}
) : (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.
)}Planen är den HEMS senast räknade fram. Ändrar du avresetid eller mål räknas den om inom några minuter.
)} {/* v770 (André 4/10): Ladda nu är en färgad knapp. Aktiv = samma färg tonad, med Avbryt. */}Bilen laddas enbart ur solöverskott, aldrig från elnätet — ingen avresetid gäller, och utan sol nås inte målet.
)} {d.mode === 'grid' && (Bilen laddas till målet före avresan, de timmar elen är billigast. Elnätet får användas hela tiden, och solen används när den är billigast. Ingen avresedag behövs.
)}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.
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.