/* ============================================================================
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)}
)}
);
}
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 (
{/* 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 && (
{/* 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.
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}