/* ============================================================================ 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) => `${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){ const t0 = r.part === 'after' ? '00:00' : gclQ(r.from); const t1 = r.part === 'before' ? '24:00' : gclQ(r.to, true); const span = `${t0}–${t1}`; return r.part ? `${span} (del av körning ${gclQ(r.whole[0])}–${gclQ(r.whole[1], true)})` : span; } 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, tomorrow: a0 >= window.GC_Q, win: r.win != null ? r.win : 0 }); })); 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, 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; const showRated = rated > 0 && Math.abs(rated - kwh) >= 0.05; 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} {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; const chip = !on ? { txt:'Avstängd', col:'#64748b' } : 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; return (
onChange({ name: e.target.value })} className="min-w-0 flex-1 bg-transparent text-base font-bold text-slate-50 outline-none placeholder:text-slate-600"/>
{chip.txt}
{/* 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 && uppmätt} {wins.length} {wins.length === 1 ? 'körfönster' : 'körfönster'}
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.
}
{/* 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 (
{lbl(p.i0)}–{lbl(p.i1, true)} {p.tomorrow ? 'i morgon' : 'i dag'} {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. */}
)}
); } /* 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({}); try { await saveFn((cur) => { const out = loads.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 }; }); setDirty(false); setToast('ok'); 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; }); 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 && (
Planerad kostnad
{anyPlan ? (
) : (
Ingen körning planerad i dag eller i morgon.
)}
Rubriktalet ä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. Varav köpt el är det du faktiskt betalar.
)}
{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 });