/* ============================================================================ GC · LAST — skjutbara laster i kollegans design ---------------------------------------------------------------------------- Hans LastView (GravioApp.jsx 2301) med hans kortform: namn-input + switch, statusprick, stor kW-siffra, kontroller, planblock under en linje, streckad "lägg till", summeringskort. TRE SKILLNADER MOT HANS FIL, alla medvetna: 1. FÄLTEN ÄR VÅRA. Han lagrar {amp, phases, hours, deadline, solPref}. Backend lagrar deferrable_loads = {name, power_kw, enabled, reports_power, windows[]}. Kontrollerna binder till backend-namnen. Ampere/faser finns inte i modellen — effekten anges i kW, som den sparas. Ett fält som inte finns i _sanitize kastas tyst (hände 4/8 med faser per bil); därför renderas ingen kontroll utan lagring bakom. 2. PLANEN KOMMER FRÅN BACKEND. Hans planFor() räknar själv fram billigaste fönstret i klienten. Vi läser deferrable_plan via adaptern (day.loadMeta) — annars visar kortet en plan som HEMS inte tänker köra. Klienten räknar ALDRIG ut ett pris själv; kronorna, kWh:en och nätdelen kommer alla ur backendens tal. 3. ETT FÖNSTER ÄR INTE HELA LASTEN (2/9). En last är en apparat med en LISTA körfönster, vart och ett med egna veckodagar, tider, körtid, bara sol och delbarhet. Fönsterlistan och dess validering ligger i gc-lastfonster.jsx; det här kortet äger lastens gemensamma fält och redovisningen. Kravlista: DESIGN_KRAVLISTA_lastfonster_2026-09-02 p1–p12. ========================================================================== */ /* Effekten defaultar till 2,0 kW, inte 0 (2/9). Backend validerar power_kw > 0 och avvisar HELA sparningen — med 0 som default såg det ut som att namnet inte fastnade (André och en användare två minuter tidigare, 2/9). Ett startvärde som går att spara är bättre än ett som måste rättas först. */ const GCL_DEFAULT = { name:'', power_kw:2.0, enabled:true, reports_power:false }; /* `existed_before` ÅTERKALLAD 6/9 (André). Övergången mäts nu automatiskt (HEMS_CONS_TRANSITION: ny apparat ⇒ ingen nedgång ⇒ ingen rättelse) och lastens effekt loggas även före aktivering. Fältet fanns aldrig i backendens staging-kod och släpptes tyst vid spar (Pydantic extra=ignore) — lägg inte tillbaka frågan. */ const gclKr = (v) => v == null ? '—' : (Math.abs(v) < 10 ? window.gcNum(v, 2) : window.gcNum(v, 0)) + ' kr'; /* Kvartsindex → klockslag. Kvart 96 och 192 är dygnets SLUT när de står som SLUTINDEX (exklusivt) — en körning som slutar vid midnatt får inte påstå att den slutar när den började. Som STARTINDEX är samma tal 00:00: en placering med start_quarter 96 börjar vid midnatt, den slutar inte där. Mappningen är därför positionsberoende — utan flaggan sa kortet "24:00–01:00" om varje nattfönster som placerades tidigast möjligt i morgon. */ const gclQ = (i, isEnd) => (isEnd && i > 0 && i % window.GC_Q === 0) ? '24:00' : window.gcLabel(i % window.GC_Q); /* Chip-texten. Ett spann får aldrig formateras som start ur ett dygn och slut ur ett annat — 114 % 96 blir 18, alltså samma klockslag som starten, och chipet påstod "04:30–04:30" eller till och med en sluttid före starten. Etiketten är gclQ, inte gcLabel(i % 96): kvart 96 och 192 är dygnets SLUT. */ function gclChip(plan, lbl){ const q = lbl || gclQ; const one = (p) => window.lpSpanTxt ? window.lpSpanTxt(p, q) : `${q(p.i0)}–${q(p.i1, true)}`; const t = plan.todayParts || [], m = plan.tmrwParts || []; const seg = (list, suffix) => list.length === 1 ? one(list[0]) + suffix : `${list.length} körningar` + suffix; if (t.length && m.length) return `Planerad ${seg(t, ' i dag')}, ${seg(m, ' i morgon')}`; if (m.length) return `Planerad ${seg(m, ' i morgon')}`; return `Planerad ${seg(t, '')}`; } /* En delad körning måste läsas som EN körning (spec §11). Utan parentesen skulle "22:00–24:00" och "00:00–03:00" se ut som två körningar i två dygn, och summan av dygnsraderna skulle inte gå ihop med lastens totaler. */ function gclRunTxt(r){ /* Minutfält (19/9): fromM/toM och m0/m1 vinner över kvartarna när de finns. */ const T = window.lpTimeLabel; const t0 = r.part === 'after' ? '00:00' : (r.fromM != null && T ? T(r.fromM) : gclQ(r.from)); const t1 = r.part === 'before' ? '24:00' : (r.toM != null && T ? T(r.toM, true) : gclQ(r.to, true)); const span = `${t0}–${t1}`; const w0 = r.m0 != null && T ? T(r.m0) : gclQ(r.whole[0]); const w1 = r.m1 != null && T ? T(r.m1, true) : gclQ(r.whole[1], true); const base = r.part ? `${span} (del av körning ${w0}–${w1})` : span; /* Krav 8 (12/9): en remnant är inte en ny körning utan gårdagens fortsättning. */ return r.remnant ? `${base} · fortsättning från i går` : base; } const GCL_FIELD = 'w-full rounded-lg border border-slate-700 bg-slate-800/70 px-2.5 py-1.5 text-sm font-bold text-slate-100 outline-none focus:border-slate-500'; const GCL_LBL = 'mb-1 text-[10px] font-semibold uppercase tracking-[0.15em] text-slate-500'; /* Planerad körning för en last, ur adapterns dygn. Index i units.loads, inte i settings — adaptern filtrerar bort avstängda laster, så listorna glider isär. Matcha på id, annars namn. */ function gclPlan(day, tomorrow, units, load){ const list = (units && units.loads) || []; let k = list.findIndex(u => load.id != null && String(u.id) === String(load.id)); if (k < 0) k = list.findIndex(u => u.name === (load.name || '')); if (k < 0 || !day) return null; const seq = tomorrow ? day.concat(tomorrow) : day.slice(); const meta = (day.loadMeta && day.loadMeta[k]) || null; const days = (meta && meta.days) || {}; /* Banden härleds ur PLANEN, inte ur den ritade kurvan. Kurvan bär både planerad effekt och LIVE-MÄTNING i samma fält (`p`), och en mätt kvart utan placering blev då ett extra "planerat" band: kortet påstod tre körningar i dag med "del 2 av 3" — på en last vars körform är sammanhängande — medan dygnsraden under visade två. Samma kort motsade sig självt. `days[].runs` kommer ur deferrable_plan. En körning som korsar midnatt ligger som två poster (redovisningsdelningen) men bär samma `whole`, så dedupen på whole ger tillbaka den ENA körningen — spec §11 kräver att den aldrig läses som två. */ const seen = new Set(); let parts = []; [0, 1].forEach(d => ((days[d] && days[d].runs) || []).forEach(r => { const key = (r.whole || [r.i0, r.i1]).join(','); if (seen.has(key)) return; seen.add(key); const a0 = r.whole ? r.whole[0] : r.i0, a1 = r.whole ? r.whole[1] : r.i1; parts.push({ i0: a0, i1: a1, m0: r.m0 != null ? r.m0 : null, m1: r.m1 != null ? r.m1 : null, tomorrow: a0 >= window.GC_Q, win: r.win != null ? r.win : 0, remnant: r.remnant === true }); })); parts.sort((a, b) => a.i0 - b.i0); /* Fallback för äldre backend utan deferrable_plan: adaptern bygger då kvartarna ur evLoad-banden och `days` är tom. Mätta kvartar utesluts — de är utfall, inte plan. */ if (!parts.length){ const ranges = window.gcRanges(seq, (x) => { const c = x.loads && x.loads[k]; const p = c && c.p; return p != null && p > 0.05 && c.measured !== true; }, false); if (!ranges.length) return null; parts = ranges.map(([a0, a1]) => ({ i0:a0, i1:a1, tomorrow: a0 >= window.GC_Q, win: 0 })); } const i0 = parts[0].i0, i1 = parts[0].i1; const at = Math.min(i0, seq.length - 1); const src = (day[Math.min(i0, day.length-1)].loads[k] || {}).src || (seq[at].loads[k] || {}).src || null; /* kWh tas ur backendens `energy_kwh`, INTE ur den ritade kurvan (spec §12). Kurvan ritar märkeffekt per kvart; så snart inlärningen planerar med ett lägre uppmätt värde glider den isär från kronorna, och kortet visade då 30 kWh med ett snittpris räknat på 20. Kurvan styr ingenting här längre. */ const kwh = [0, 1].reduce((a, d) => a + (days[d] ? days[d].energyKwh : 0), 0); const today = parts.filter(p => !p.tomorrow); const tmrw = parts.filter(p => p.tomorrow); return { i0, i1, kwh, src, tomorrow: i0 >= window.GC_Q, parts, todayParts: today, tmrwParts: tmrw, bothDays: today.length > 0 && tmrw.length > 0, days, byWin: (meta && meta.byWin) || {}, costKr: meta && meta.costKr != null ? meta.costKr : null, energyKwh: meta && meta.energyKwh != null ? meta.energyKwh : null }; } /* ---- Dygnsrad (kravlista p8–p12) -------------------------------------- Ett dygn, ett tal per storhet. Rubriktalet är `cost_kr` — den ekonomiska kostnaden, sol värderad till säljpris — eftersom det är samma tal HEMS optimerar på; annars ser besluten irrationella ut. "Varav köpt el" är det faktiska utlägget och visas BARA när backend levererat `grid_cost_kr`. Snittpriset räknas alltid på huvudtalet, aldrig på det angivna. */ function GcLastDayRow({ label, d, prelim, showRuns }){ if (!d) return null; const kwh = d.energyKwh; const rated = d.ratedKwh; /* Krav 4 (12/9): "Angivet" = angiven effekt × körtid, "Planerat" = energy_kwh. Båda visas så snart det angivna finns — skillnaden ÄR informationen. */ const showRated = rated > 0; const ore = kwh > 0.001 && !d.costMissing && d.costKr != null ? Math.round((d.costKr / kwh) * 100) : null; /* Kostnaden UTELÄMNAS när planen inte bär den (planer skrivna före 16/8, och korsande körningar där ett *_after-tal saknas så delningen inte går att räkna). Ett "0,00 kr" i grönt betyder "helt soltäckt körning" — att visa det för ett tal vi inte fått vore att hitta på ett pris. */ const showCost = !prelim && !d.costMissing; return (
{label} Planerat {window.gcNum(kwh, 1)} kWh {prelim ? ( pris visas när priserna släppts, ca 13:00 ) : showCost ? ( {gclKr(d.costKr)} ) : ( kostnad saknas i planen )}
{showRated ? <>Angivet {window.gcNum(rated, 1)} kWh : ''} {showCost && !d.gridMissing && ( varav köpt el {gclKr(d.gridCostKr)} )}
sol {window.gcNum(d.solarKwh, 1)} kWh {showCost && ore != null && {ore} öre/kWh}
{showRuns && (d.runs || []).length > 0 && (
{d.runs.map((r, i) => {gclRunTxt(r)})}
)}
); } function GcLastCard({ load, plan, wins, errs, err422, runs, now, prelim, onChange, onWins, onRemove }){ const l = { ...GCL_DEFAULT, ...load }; const on = l.enabled !== false; const col = plan && window.GC_LOAD_COL[plan.src] ? window.GC_LOAD_COL[plan.src] : '#a3e635'; /* EN etikettfunktion i hela kortet: gclQ. Den lokala gcLabel(i % GC_Q) gjorde kvart 192 till "00:00", så bandraden sa "23:00–00:00" om samma körning som dygnsraden tre rader under kallade "23:00–24:00". Midnatt är körningens slut, inte dess start. */ const lbl = gclQ; /* p4: har lasten inget fönster idag eller imorgon finns ingen post att läsa — planhorisonten är två dygn. Då är veckodagen det ärliga svaret. */ const d0 = ((now || new Date()).getDay() + 6) % 7; const nextDay = (!plan && window.lwCoversDay && !window.lwCoversDay(wins, d0) && !window.lwCoversDay(wins, (d0 + 1) % 7)) ? window.lwNextDayName(wins, now) : null; /* Manuell körning (§23) vinner statusraden så läget syns även ihopfällt (André 26/9). */ const [manAll] = window.useLoadManual ? window.useLoadManual(true) : [null]; const man = on && window.lwManualFor ? window.lwManualFor(manAll, l.id) : null; const manHM = (t) => { if (t == null) return ''; const d = new Date(Number(t) * (Number(t) < 1e12 ? 1000 : 1)); return isNaN(d) ? '' : String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }; const chip = !on ? { txt:'Avstängd', col:'#64748b' } : man && !man.on && man.off_deviation ? { txt:'Avstängd men drar ändå ström', col:'#fde68a' } : man && man.on ? { txt: (man.mode === 'solar' ? 'Sol · på till midnatt' : 'Manuellt på till midnatt') + (() => { const x = window.lwManStep ? window.lwManStep(man) : (man.since ? 'sedan ' + manHM(man.since) : ''); return x ? ' · ' + x : ''; })(), col: man.mode === 'solar' ? '#fcd34d' : '#2563eb' } : plan ? { txt: gclChip(plan, lbl), col } : nextDay ? { txt: `Nästa körning ${nextDay}`, col:'#94a3b8' } : { txt:'Ingen körning planerad', col:'#94a3b8' }; const days = (plan && plan.days) || {}; const pwrBad = !(l.power_kw > 0); /* Inlärd effekt (DESIGN_KRAVLISTA_inlard_effekt 6/9). Reglaget behåller ALLTID användarens egna tal medan det går att röra — flyttade tummen till det uppmätta värdet skulle nästa drag skriva 6,0 kW som "angivet" och tysta skriva om det användaren satt. Först när reglaget är låst visar det det tal HEMS planerar med, eftersom inget kan skrivas därifrån. */ const lp = window.lwLearnedPower ? window.lwLearnedPower(runs, l) : null; const pwrLocked = !!(lp && lp.locked); const pwrTxt = window.lwPowerText ? window.lwPowerText(lp, l.name) : null; /* Krav 5 (12/9) + krav 12 (14/9): effekttaksraden. Bara när backend säger peak_raises_to_rated för något av lastens fönster; X är effective_power_kw (det enda stället det talet visas), fallback angiven effekt. */ const peakRow = (() => { const byLoad = runs && runs.loads && l.id != null ? runs.loads[String(l.id)] : null; if (!byLoad || typeof byLoad !== 'object') return null; const r = Object.keys(byLoad).map(k => byLoad[k]).find(r => r && r.peak_raises_to_rated === true); if (!r) return null; const kw = r.effective_power_kw != null ? Number(r.effective_power_kw) : r.rated_power_kw != null ? Number(r.rated_power_kw) : Number(l.power_kw); return isFinite(kw) ? `I timmar med effekttak räknar HEMS med ${window.gcNum(kw, 1)} kW` : null; })(); /* Hopfällt kort (André 25/9: tre laster med tre fönster blir för långt). Sparad last börjar hopfälld, ny last öppen. Fel tvingar kortet öppet så ingen röd rad göms. Valet sparas per last som vyinställning. */ const [openPref, setOpen] = window.useFoldPref ? window.useFoldPref('last.kort.' + (l.id != null ? l.id : 'ny'), l.id == null) : [true, () => {}]; const hasErr = !!(err422 && (err422.msg || err422.rows)) || !!(errs && (Array.isArray(errs) ? errs.some(Boolean) : Object.keys(errs).length)) || (pwrBad && !pwrLocked); const open = openPref || hasErr; const [editName, setEditName] = React.useState(!l.name); const [winNames] = window.useLwWinNames ? window.useLwWinNames() : [{}]; const [winOrd] = window.useLwWinOrder ? window.useLwWinOrder() : [{}]; const winView = window.lwViewOrder ? window.lwViewOrder(l, wins, winOrd) : wins.map((_, i) => i); return (
{/* Namnet är text; pennan öppnar redigering (André 25/9). Ny last utan namn börjar i redigeringsläge. Enter/Esc/klick utanför stänger. */} {editName ? ( onChange({ name: e.target.value })} onBlur={() => setEditName(false)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') e.currentTarget.blur(); }} className="min-w-0 flex-1 rounded-lg border border-lime-400 bg-slate-950/40 px-2.5 py-1 text-base font-bold text-slate-50 outline-none placeholder:text-slate-600"/> ) : ( {l.name || 'Namnlös last'} )} {/* Hela ytan mellan pennan och ✕ fäller kortet (André 25/9); pilen ligger kvar längst till höger och ser ut som förut. */} {!hasErr ? ( ) : !editName && }
{/* Öppet kort: knapparna nedanför visar läget, taggen skulle bli dubbel (André 26/9). */} {man && man.on && !open ?
{man.mode === 'solar' ? 'Sol · på till midnatt' : 'Manuellt · på till midnatt'}{man.since && sedan {manHM(man.since)}}
: (man && man.on) ? null :
{chip.txt}
} {/* Manuell körning (§23, André 26/9): bara sparade laster som är på, och bara när backend svarar. */} {open && on && l.id != null && window.GcManualRun && } {/* Backendens avslag på LASTEN (inte på ett fönster) hör här uppe: det handlar oftast om namnet eller effekten, och längst ner under fönsterlistan hittade ingen det. */} {err422 && err422.msg && (
{err422.msg}
)}
{window.gcNum(lp && lp.applied ? lp.effective : l.power_kw, 1)} kW {lp && lp.applied && {lp.measured ? 'uppmätt' : 'inlärt'}}
{!open && ( )} {open && ( {/* Låst effekt (inlärd): reglaget och texten döljs, talet står redan stort ovanför (André 26/9: "2,9 tre gånger"). */} {!pwrLocked &&
Effekt
{/* Taket följer värdet: backend har ingen övre gräns på power_kw, och en fast max pinnade tummen vid 11 för större laster. */} onChange({ power_kw: Number(e.target.value) })} className={`w-full ${pwrLocked ? 'accent-slate-500 opacity-70' : 'accent-lime-400'}`}/> {window.gcNum(pwrLocked ? lp.effective : l.power_kw, 1)}
{/* Krav 1–3: texten ersätter reglaget när det är låst, kompletterar det medan inlärningen pågår, och är "angivet värde" när inget mätts — samma ordval som kWh-raden i fönsterlistan. */} {pwrTxt ?
{pwrTxt}
:
angivet värde
} {pwrBad && !pwrLocked &&
Effekten måste vara större än 0 — HEMS kan inte planera en last utan effekt, och sparningen avvisas.
}
} {peakRow &&
{peakRow}
} {/* Fönsterlistan (p1–p3). Minst en rad, max 21. */}
Körfönster
{/* DESIGN_KRAVLISTA_lastknapp 2026-09-02 §1–3 — reports_power. Samma fält som settings-editorn; hjälptexterna verbatim ur kravlistan. */}
{l.reports_power === true ? 'Homey skickar lastens verkliga effekt till HEMS. HEMS drar av den från husets förbrukning medan lasten körs, så prognosen lär sig huset utan lasten. Kräver att du kopplar ett flow-kort i Homey som rapporterar lastens effekt (se hjälpen).' : 'HEMS antar att lasten drar sin angivna effekt hela körtiden och drar av det från husets förbrukning medan lasten körs (t.ex. 2 kW i 30 min = 1 kWh). Drar lasten mindre än angivet blir husets sparade förbrukning för låg just de timmarna. Prognosen påverkas inte – timmar med antagen last används inte i inlärningen.'}
Planerad körning
{!on ? (
Lasten är avstängd — HEMS planerar ingen körning.
) : !plan ? (
{nextDay ? `Inget fönster i dag eller i morgon. Nästa körning ${nextDay}.` : (wins.some(w => w.solar_only) ? 'Ingen körning i planen. Med Bara sol körs fönstret bara när solöverskottet räcker till hela effekten.' : 'Ingen körning i planen. Nästa planering görs inom ~15 min efter en ändring.')}
) : (
{(plan.parts || [plan]).map((p, i) => { /* "del X av Y" kräver att fönstret FÅR delas. Ett `consecutive`-fönster kan per definition inte ha delar — men ett fönster över midnatt får legitimt två placeringar i 48-timmarshorisonten (nattens fortsättning och nästa kvälls start, 22 timmar isär). Dygn + fönsterindex räckte därför inte: kortet sa "del 1 av 2" på en last vars båda "Får delas upp"-piller var släckta i samma vy. */ const wCfg = wins && wins[p.win]; const splittable = !!(wCfg && wCfg.mode === 'split'); const sib = splittable ? (plan.parts || []).filter(q => q.tomorrow === p.tomorrow && q.win === p.win) : []; const n = sib.indexOf(p) + 1; return (
{window.lpSpanTxt ? window.lpSpanTxt(p, lbl) : `${lbl(p.i0)}–${lbl(p.i1, true)}`} {p.tomorrow ? 'i morgon' : 'i dag'}{p.remnant ? ' · fortsättning från i går' : ''} {i === 0 && plan.src && {plan.src}} {/* Bara när fönstret får delas OCH har fler än en del. */} {sib.length > 1 && ( del {n} av {sib.length} )}
); })}
{/* p8–p12: två dygnsrader i stället för en 48-timmarssumma. */}
)}
{!hasErr && ( )}
)}
); } /* ---- Planerad kostnad, alla laster (André 25/9, mockup A) ------------------ Tabell: en rad per storhet, en kolumn per dygn. Cellerna har fast plats så inget hoppar när morgondagens pris kommer. "Angivet" är borttaget här. Var resten av energin kommer ifrån (batteri / köpt) visas inte än: planen bär bara energy_kwh och solar_kwh, och en differens vore ett härlett tal (BACKEND_BEHOV §20). "–" = talet finns inte, aldrig noll. */ function GcLastSummary({ tot, anyPlan, prelim }){ const [info, setInfo] = React.useState(false); const cell = (d, isTmrw, f) => { if (!d) return –; return f(d, isTmrw && prelim); }; const kr = (d, pre) => pre ? ca 13:00 : d.costMissing ? saknas : {gclKr(d.costKr)}; const grid = (d, pre) => (pre || d.costMissing || d.gridMissing) ? – : {gclKr(d.gridCostKr)}; const ore = (d, pre) => { const o = !pre && !d.costMissing && d.costKr != null && d.energyKwh > 0.001 ? Math.round((d.costKr / d.energyKwh) * 100) : null; return o == null ? – : {o} öre; }; const kwh = (k) => (d) => (d[k] != null && !d[k + 'Miss']) ? {window.gcNum(d[k], 1)} kWh : –; const krOf = (k) => (d, pre) => (pre || d.costMissing || d[k] == null || d[k + 'Miss']) ? – : {gclKr(d[k])}; /* Uppställning (André 25/9): delarna ovanför, summan under strecket. Sol- och batterirader finns bara när backend skickar talen (§20) — resten räknas aldrig fram som en differens. */ const has = (k) => [tot[0], tot[1]].some(d => d && d[k] != null && !d[k + 'Miss']); const rows = [ ['Köpt el', grid], has('solarValueKr') && ['Från sol', krOf('solarValueKr')], has('batteryValueKr') && ['Från batteri', krOf('batteryValueKr')], ['Kostnad', kr, 'sum'], ['snittpris', ore, 'sub'], 'gap', has('gridKwh') && ['Köpt el', kwh('gridKwh')], ['Från sol', kwh('solarKwh')], has('batteryKwh') && ['Från batteri', kwh('batteryKwh')], ['Planerat', kwh('energyKwh'), 'sum'], ].filter(Boolean); const H = 'text-right text-[10px] font-semibold uppercase tracking-[0.15em] text-slate-400'; return (
Planerad kostnad
{anyPlan ? (
I dagI morgon {rows.map((r, i) => r === 'gap' ? : ( {r[0]} {cell(tot[0], false, r[1])} {cell(tot[1], true, r[1])} ))}
) : (
Ingen körning planerad i dag eller i morgon.
)} {anyPlan && prelim &&
Priset för i morgon kommer ca 13:00.
} {info && (
Kostnad är körningens ekonomiska kostnad: solel värderas till vad den hade gett vid försäljning, eftersom det är samma tal HEMS lägger körningen efter. Köpt el är det du faktiskt betalar. Raderna från sol och från batteri visas när planen skickar dem.
)}
); } /* Sparvägen är gcSaveSettings (gc-core): GET → byt ENBART deferrable_loads → POST, med settings_rev och en omläsning vid 409. PATCH /api/settings tar bara ev_session och 422:ar på deferrable_loads. */ function GcLast(props){ /* Fönsterlogiken ligger i lastfonster.js. Saknas filen vid deploy ska fliken DEGRADERA, inte bli blank: utan vakten kastar första lw*-anropet och hela Last-fliken försvinner utan förklaring (ingen ErrorBoundary i bundlen). De fem nya filerna hör ihop — det här är hur användaren får veta att en av dem inte kom med. */ if (!window.lwFromLoad || !window.GcWindowList) { return (
Lastvyn kunde inte laddas — en delfil saknas i den här versionen. Läs in sidan igen; står det kvar är det ett driftfel, och dina laster är orörda.
); } /* "Inte hämtat än" är INTE "inga laster". Utan vakten blir incoming = [] under den dryga sekund settings saknas, vyn påstår "inga skjutbara laster ännu" — och just det tomtillståndet lockar till klick på "+ Lägg till last". Klickar användaren där sätts dirty, re-seedingen hoppas över permanent, och save() postar deferrable_loads med BARA den nya lasten: hushållets befintliga laster raderas ur Firestore. Samma rotorsak som GcInstallningar hade, men här med dataförlust som följd. */ if (!props.settings) { return (
Hämtar laster…
); } return ; } /* Fönsterlistan normaliseras EN gång, vid seedningen: en gammal last utan `windows` blir en rad med alla veckodagar (spec §2). Därefter är .windows sanningen i vyn, och lwToPayload utelämnar toppnivåns fönsterfält vid spar. */ const gclSeed = (arr) => arr.map(l => ({ ...(l || {}), windows: window.lwFromLoad(l) })); function GcLastBody({ settings, day, tomorrow, units, now, tomorrowPrelim, onSave, onSaved }){ /* Sparvägen är INJICERBAR. Standard är window.gcSaveSettings (GET+POST mot /settings/api/), men testharnessen skickar en egen — den kör i samma origin med den skarpa sessionen, och ett klick på "Spara laster" där skrev 2/9 fixturdata över hushållets verkliga laster. En global stubb räckte inte: den kan förlora mot scriptordning och cache. Med en prop kan harnessen inte spara ens om någon lägger tillbaka globalen. */ const saveFn = onSave || window.gcSaveSettings; const incoming = Array.isArray(settings && settings.deferrable_loads) ? settings.deferrable_loads : []; const sig = JSON.stringify(incoming); const [loads, setLoads] = React.useState(() => gclSeed(incoming)); const [dirty, setDirty] = React.useState(false); const [saving, setSaving] = React.useState(false); const [toast, setToast] = React.useState(''); const [err422, setErr422] = React.useState({}); React.useEffect(() => { if (!dirty) setLoads(gclSeed(incoming)); }, [sig]); /* Uppmätt energi (p5). Hämtas bara när det finns en last som rapporterar effekt — utan reports_power mäts inget och endpointen har inget att ge. */ const runs = window.useLoadRuns(loads.some(l => l && l.reports_power === true)); const touch = () => { setDirty(true); setToast(''); setErr422({}); }; const upd = (i, patch) => { setLoads(loads.map((l, k) => k === i ? { ...l, ...patch } : l)); touch(); }; const updWins = (i, wins) => upd(i, { windows: wins }); // Max 3 laster (2/9). Spärren gäller även här, inte bara settings-editorn. const add = () => { if (loads.length >= 3) return; setLoads([...loads, { ...GCL_DEFAULT, name:`Last ${loads.length + 1}`, windows: window.lwFromLoad(null) }]); touch(); }; const del = (i) => { setLoads(loads.filter((_, k) => k !== i)); touch(); }; const winErrs = loads.map(l => window.lwValidate(l && l.windows)); const badWins = winErrs.some(e => Object.keys(e).length > 0); /* Effekten valideras här och inte bara i backend: 0 kW ger 422 på HELA dokumentet, och då ser det ut som att inget av det man skrev fastnade. */ const badPwr = loads.some(l => l && l.enabled !== false && !(l.power_kw > 0)); const blocked = badWins || badPwr; const save = async () => { setSaving(true); setErr422({}); /* §19 (v598): fönsternamn sparas hos backend. Namn som bara finns i ui_prefs winNames följer med nu och tas bort där när sparningen lyckats. */ const migrated = loads.map((l) => window.lwWithLegacyNames ? window.lwWithLegacyNames(l || {}) : (l || {})); try { await saveFn((cur) => { const out = migrated.map(l => { const rest = window.lwToPayload(l || {}); const id = rest.id; delete rest.id; return (id != null && id !== '') ? { id, ...rest } : rest; }); return { ...cur, deferrable_loads: out }; }, 'lastkort'); setLoads(migrated); setDirty(false); setToast('ok'); if (window.lwForgetLegacyNames) window.lwForgetLegacyNames(migrated); if (onSaved) onSaved(); } catch (e) { /* 422 är inte ett tekniskt fel utan ett svar: backend är sanningen om fönsterreglerna och dess svenska text visas vid raden den pekar på. */ if (e && e.status === 422){ setErr422(window.lw422(e.body)); setToast('invalid'); } else setToast(e && /ändrades av någon annan/.test(e.message) ? 'conflict' : 'err'); } setSaving(false); }; const plans = loads.map(l => (l.enabled === false) ? null : gclPlan(day, tomorrow, units, l)); /* Summering per KALENDERDYGN, inte över 48 timmar. Talen kommer ur backendens fält per körning; klienten summerar dem men räknar aldrig om dem. `gridMissing` smittar uppåt: kan en enda körning inte redovisa sin nätdel får raden inte visa en delsumma som ser komplett ut. */ const tot = [0, 1].map(d => { const src = plans.map(p => p && p.days && p.days[d]).filter(Boolean); if (!src.length) return null; const o = { energyKwh:0, solarKwh:0, costKr:0, gridCostKr:0, ratedKwh:0, gridMissing:false, costMissing:false, runs:[] }; src.forEach(x => { ['energyKwh', 'solarKwh', 'costKr', 'gridCostKr', 'ratedKwh'].forEach(k => { o[k] += x[k] || 0; }); if (x.gridMissing) o.gridMissing = true; if (x.costMissing) o.costMissing = true; (window.GC_LD_OPT || []).forEach(k => { if (x[k + 'Miss'] || x[k] == null) o[k + 'Miss'] = true; else o[k] = (o[k] || 0) + x[k]; }); }); return o; }); const anyPlan = tot.some(Boolean); return (
{window.GcBetaNote && ( Skjutbara laster är nytt och ännu inte färdigtestat av oss. Planeringen kan flytta en körning, hoppa över den eller visa fel kostnad. Kontrollera att lasten körts som du tänkt, och styr den själv om något måste bli klart i tid. )} {loads.length === 0 && (
Inga skjutbara laster ännu. Lägg till en förbrukare som får flyttas i tiden — varmvattenberedare, poolpump, torkskåp — så lägger HEMS den på dygnets billigaste timmar.
)} {loads.map((l, i) => ( upd(i, p)} onWins={(w) => updWins(i, w)} onRemove={() => del(i)}/> ))} {loads.length < 3 ? ( ) : (
Max 3 laster
)} {loads.length > 0 && }
{toast === 'ok' && Sparat — planeras om inom ~15 min} {toast === 'err' && Kunde inte spara — försök igen} {toast === 'invalid' && Sparades inte — se det röda fältet} {toast === 'conflict' && Ändrat på annat håll — läs in sidan igen} {badPwr && !toast && Ange en effekt större än 0} {badWins && !badPwr && !toast && Rätta fönstren först} {dirty && !toast && !blocked && Osparade ändringar}
); } Object.assign(window, { GcLast, GcLastBody, GcLastCard, GcLastDayRow, GCL_DEFAULT, gclPlan, gclKr, gclChip, gclQ, gclRunTxt, gclSeed });