/* ============================================================================ GC · LASTFÖNSTER (vy) — fönsterlistan på Last-fliken ---------------------------------------------------------------------------- All logik ligger i lastfonster.js (lw*) och delas med inställningssidan. Den här filen är bara Tailwind-renderingen i GC-formspråket. Kravlista: DESIGN_KRAVLISTA_lastfonster_2026-09-02 p1–p3, p5. ========================================================================== */ const LW_F = 'w-full rounded-lg border border-slate-700 bg-slate-800/70 px-2 py-1.5 text-sm font-bold text-slate-100 outline-none focus:border-slate-500'; const LW_L = 'mb-1 text-[10px] font-semibold uppercase tracking-[0.15em] text-slate-500'; const LW_PILL = (on) => `rounded-lg border px-2 py-1 text-[11px] font-bold transition-colors ${ on ? 'border-lime-400 bg-lime-500/15 text-lime-300' : 'border-slate-700 bg-slate-800/70 text-slate-400 hover:text-slate-200'}`; /* Egna fönsternamn (André 25/9). Nyckel = last-id + backendens wid, så namnet följer fönstret vid omordning. Nya fönster har inget wid förrän lasten sparats och kan därför inte namnges förrän då. */ const lwNameKey = (load, w) => (load && load.id != null && w && typeof w.wid === 'string') ? `${load.id}:${w.wid}` : null; const lwNameOk = (v) => !!v && typeof v === 'object' && !Array.isArray(v); function useLwWinNames(){ const pair = window.useUiPref ? window.useUiPref('winNames', {}, lwNameOk) : [{}, () => {}]; const map = pair[0] || {}; const setName = (key, txt) => { if (!key) return; const cur = (window.uiPrefs && window.uiPrefs.get('winNames', {})) || {}; const out = lwNameOk(cur) ? { ...cur } : {}; const t = String(txt || '').trim().slice(0, 24); if (t) out[key] = t; else delete out[key]; pair[1](out); }; return [map, setName]; } /* §19 (v598): namnen sparas hos backend i windows[].name. ui_prefs winNames är bara det gamla lagret: namn där följer med i nästa sparning från Last-fliken och tas bort när sparningen lyckats. Ett fönster med eget name-fält (även tomt i utkastet) vinner alltid över det gamla lagret. */ const lwLegacyNames = () => { const m = window.uiPrefs && window.uiPrefs.get('winNames', {}); return lwNameOk(m) ? m : {}; }; function lwWithLegacyNames(load){ if (!load || !Array.isArray(load.windows)) return load; const m = lwLegacyNames(); return { ...load, windows: load.windows.map((w) => { if (!w || typeof w.name === 'string') return w; const k = lwNameKey(load, w); return k && m[k] ? { ...w, name: window.lwWinNameClean(m[k]) } : w; }) }; } function lwForgetLegacyNames(loads){ const m = lwLegacyNames(); if (!window.uiPrefs) return; const ids = new Set((loads || []).map((l) => l && l.id).filter((x) => x != null).map(String)); const keys = Object.keys(m).filter((k) => ids.has(k.split(':')[0])); if (!keys.length) return; const out = { ...m }; keys.forEach((k) => { delete out[k]; }); window.uiPrefs.set('winNames', out); } /* Index som planen och mätningen är nycklade på (window_idx = backendens ordning). Efter flytt eller borttagning bär fönstret sitt ursprungliga index i _src tills vyn seedas om från servern. -1 = nytt fönster. */ const lwSrcIdx = (w, i) => (w && w._src != null) ? w._src : i; const lwPin = (list) => list.map((w, k) => (w._src != null) ? w : { ...w, _src: (typeof w._k === 'string' && w._k.indexOf('new-') === 0) ? -1 : k }); /* Visningsordning (André 25/9): att flytta ett fönster är BARA grafiskt. Backendens ordning (window_idx, payload) rörs inte, så plan, mätning och inlärning följer alltid rätt fönster. Ordningen sparas i ui_prefs winOrder { '': [wid, …] }. Fönster utan wid, eller som inte finns i listan, hamnar sist i backendens ordning. Returnerar riktiga index. */ const lwOrdOk = (v) => !!v && typeof v === 'object' && !Array.isArray(v); function useLwWinOrder(){ return window.useUiPref ? window.useUiPref('winOrder', {}, lwOrdOk) : [{}, () => {}]; } function lwViewOrder(load, list, ordMap){ const idx = list.map((_, i) => i); const ord = load && load.id != null && ordMap ? ordMap[load.id] : null; if (!Array.isArray(ord) || !ord.length) return idx; const pos = (w) => { const p = w && typeof w.wid === 'string' ? ord.indexOf(w.wid) : -1; return p < 0 ? Infinity : p; }; return idx.sort((a, b) => (pos(list[a]) - pos(list[b])) || (a - b)); } const LW_ICO = 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-800 hover:text-slate-100 disabled:opacity-25 disabled:hover:bg-transparent'; const LwPen = () => ; const LwChev = ({ up }) => ; function GcWindowRow({ win, idx, count, canMove, err, measured, reportsPower, showMeasured, load, planWin, hasPlan, onChange, onRemove, onMove, label, nameKey, onRename }){ const w = { ...window.LW_WIN_DEFAULT, ...(win || {}) }; const [editing, setEditing] = React.useState(false); const days = Array.isArray(w.days) ? w.days : []; const span = window.lwSpan(w); const [open, setOpen] = React.useState(false); const toggleDay = (d) => { const next = days.indexOf(d) >= 0 ? days.filter(x => x !== d) : days.concat([d]).sort((a, b) => a - b); onChange({ days: next }); }; const hist0 = (measured && Array.isArray(measured.history)) ? measured.history.slice(0, 10) : []; /* Manuell körning (§23): backend märker dygnet excluded:'manual'. Tills dess, och för dagens rad som ännu inte stängts, läses läget ur /api/load-manual så raden syns som "räknas ej" redan medan den pågår. */ const [manAll] = window.useLoadManual ? window.useLoadManual(true) : [null]; const manSt = window.lwManualFor ? window.lwManualFor(manAll, load && load.id) : null; /* Dagens manuella energi (4/10) till dagens rad: radens kwh är fönstrets körning. André: "Totalen räcker", så dagens rad visar fönstret + manuellt som ett tal, blått (gult för sol) som manuella dygn stått sedan 26/9. Backend 5/10: `manual_kwh_outside_windows` är det som får läggas till utan dubbelräkning; `manual_kwh_in_windows` ligger redan i fönstrets tal och står som "varav … under planerad körning". Utan delningen (äldre backend) gäller day_kwh och överlappsregeln från 4/10. */ const manDay = manSt && window.lwManualDay ? window.lwManualDay(manSt) : null; const manSplit = !!(manDay && manDay.split); const manDayKwh = manSplit ? manDay.outside : manSt ? (manSt.day_kwh ?? null) : null; const manInWin = manSplit && manDay.inWin > 0.005 ? manDay.inWin : null; const manTodayKwh = (h) => h && h.day_key === todayKey && manDayKwh != null && isFinite(Number(manDayKwh)) ? Number(manDayKwh) : null; /* Ligger den manuella tiden i radens körning (started_at–ended_at) räknas samma energi i båda (backend 4/10): då står talen var för sig. Behövs inte när backend delar energin (5/10). */ const sepToday = (h) => { if (manSplit || manTodayKwh(h) == null || !window.lwManualAdd || !load || load.id == null) return false; const r = window.lwManualAdd(manAll, { enabled: true, loads: { [String(load.id)]: { w: { history: [h] } } } }, [], load.id); return !r || r.overlap; }; const exTxt = (h, ex) => window.lwExclTxt ? window.lwExclTxt(ex, manTodayKwh(h), sepToday(h), h && h.day_key === todayKey ? manInWin : null) : (window.LW_EXCL_TXT[ex] || 'räknas ej'); const sumOf = (h, ex) => /^manual/.test(ex || '') && manTodayKwh(h) != null && !sepToday(h); const kwhOf = (h, ex) => { if (!h || h.kwh == null) return null; return sumOf(h, ex) ? Number(h.kwh) + manTodayKwh(h) : Number(h.kwh); }; const numCls = (h, ex, base) => sumOf(h, ex) ? 'font-bold ' + (ex === 'manual_solar' ? 'text-amber-300' : 'text-blue-400') : base; const todayKey = (() => { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); })(); /* v676: backend räknar bort dygnet först efter 10 min manuell körning (manual_on_sec, day_on_sec hos äldre backend), så dagens rad förmärks bara då. */ const manToday = manSt && (manDay ? manDay.onSec > 600 : Number(manSt.day_on_sec) > 600) ? (manSt.mode === 'solar' ? 'manual_solar' : 'manual') : null; const hist = hist0.map(h => (manToday && h && !h.excluded && h.day_key === todayKey) ? { ...h, excluded: manToday } : h); const mTxt = window.lwRunsText(measured); const planned = showMeasured && reportsPower ? window.lwPlanned(measured, load, w) : null; const prog = showMeasured && reportsPower ? window.lwProgress(measured) : null; const running = !!(measured && measured.running === true); /* Krav 3 (12/9). Talet på kortet är PLANENS energy_kwh för fönstrets körning (matchad på load_id + window_idx i adaptern). Inlärningens tal just nu (`planned_kwh`) är det NÄSTA plan kommer använda — avviker det visas det som egen rad, så 8,72 och 9,12 aldrig mer står under samma rubrik. Utan placering finns inget tal för körningen, bara nästa plan. */ const q = (i, isEnd) => window.gclQ ? window.gclQ(i, isEnd) : window.gcLabel(i % 96); /* Klar körning (André 26/9: "Fönstret är klart, klockan är efter, varför står det Planerar med i dag?"): är dagens placering passerad lyfts nästa placering (oftast i morgon) fram som Planerar med, och dagens står som "Körde … i dag". */ const nowQ = (() => { const d = new Date(); return d.getHours() * 4 + d.getMinutes() / 15; })(); const doneToday = planWin && planWin.day === 0 && planWin.i1 <= nowQ ? planWin : null; const upcoming = doneToday ? (planWin.next || null) : planWin; const runKwh = upcoming && upcoming.energyKwh != null ? Number(upcoming.energyKwh) : null; const nextKwh = planned && planned.kwh != null ? Number(planned.kwh) : null; const showNext = nextKwh != null && runKwh == null; const winName = window.lwWinName ? window.lwWinName(w) : ''; return (
{editing ? ( { onRename(e.target.value); setEditing(false); }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { e.currentTarget.value = label || ''; e.currentTarget.blur(); } }} className="min-w-0 flex-1 rounded-md border border-lime-400 bg-slate-950/40 px-2 py-0.5 text-xs font-bold text-slate-100 outline-none"/> ) : ( {label || `Fönster ${idx + 1}`} {winName} )} {count > 1 && !editing && ( )}
{window.LW_DAY_SHORT.map((s, d) => ( ))}
{/* Tre fält på en rad kräver ~320 px: på smala mobiler bryts körtiden ner på egen rad, och tidsfälten får min-w-0 — iOS ger dem annars en egen minsta bredd och de gick in i varandra (André 13/9). */}
Tidigast
onChange({ earliest: e.target.value })} className={LW_F + ' min-w-0 appearance-none'} style={{ ...window.GC_MONO, colorScheme: 'dark' }}/>
Senast klar
onChange({ latest: e.target.value })} className={LW_F + ' min-w-0 appearance-none'} style={{ ...window.GC_MONO, colorScheme: 'dark' }}/>
Körtid (h)
{/* Råvärdet sparas som det skrivs — tomt och 0 tillåtna i fältet. Ett orimligt värde stoppar Spara med lwValidate:s text på raden i stället för att tyst skrivas om till 0,25 (André 13/9). */} onChange({ duration_hours: e.target.value })} className={LW_F + ' min-w-0'} style={{ ...window.GC_MONO, colorScheme: 'dark' }}/>
{w.mode === 'split' && ( )}
{w.solar_only === true &&
{window.LW_HELP_SOLAR}
} {span && span.wrap &&
{window.LW_HELP_WRAP}
} {err &&
{err}
} {/* Ett block per fönster, högst tre rader (André 26/9: "väldigt rörigt, mycket text och onödigt"): 1) när nästa körning ligger och dess energi (snittet när det finns, §22), 2) snitt · antal körningar + Visa körningar, 3) inlärningens framsteg medan den pågår. Passerade körningar syns i Schema, inte här. */} {(hasPlan || showMeasured) && (
{/* Dagens uppmätta körning (André 26/9): när ingen körning ligger framåt visas vad fönstret faktiskt drog i dag, ur samma history-rad som listan. */} {(() => { const t = hist0.find(h => h && h.day_key === todayKey && h.kwh != null); if (!t) return null; const ex = t.excluded ? String(t.excluded) : null; /* Dagens uppmätta körning står alltid överst när den finns (André 26/9), ovanför nästa körning. */ return
I dag · uppmätt{window.lwNum(kwhOf(t, ex), 1)} kWh{ex && {exTxt(t, ex)}}
; })()} {hasPlan && (runKwh != null ? (
{/* Samma stil som Snitt-raden (André 26/9: raden var större och i versaler). */} {upcoming.day === 1 ? 'I morgon' : 'I dag'} · prognos {window.lpSpanTxt ? window.lpSpanTxt(upcoming, q) : `${q(upcoming.i0)}–${q(upcoming.i1, true)}`}{upcoming.remnant ? ' · från i går' : ''} · {window.lwNum(nextKwh != null && planned && planned.src !== 'specified' ? nextKwh : runKwh, 1)} kWh
) : (
{hist0.some(h => h && h.day_key === todayKey && h.kwh != null) ? 'Ingen körning i morgon' : 'Ingen körning i dag eller i morgon'}
))} {showMeasured && !reportsPower &&
Slå på förbrukningsläsning för att mäta
} {showMeasured && reportsPower && ( {(() => { const n = measured ? Number(measured.n) || 0 : 0; const avg = measured && measured.learned_kwh != null ? measured.learned_kwh : (measured && measured.ewma_kwh != null ? measured.ewma_kwh : null); return
{avg != null ? Snitt{window.lwNum(avg, 1)} kWh· {n} {n === 1 ? 'körning' : 'körningar'} : {running ? 'Mäter körningen just nu…' : 'Ingen körning mätt ännu'}} {hist.length > 0 && }
; })()} {prog && (
Inlärning {prog.n} av {prog.need} körningar{running ? ' · mäter nu' : ''}
)} )}
)} {showMeasured && open && hist.length > 0 && (
{hist.map((h, i) => { const ex = h && h.excluded ? String(h.excluded) : null; return (
{h && h.day_key ? String(h.day_key).slice(5) : '—'} {h && h.kwh != null ? window.lwNum(kwhOf(h, ex), 2) + ' kWh' : '—'} {ex && {exTxt(h, ex)}}
); })}
)}
); } function GcWindowList({ load, wins, errs, rowErrs, runs, plan, onChange }){ const list = Array.isArray(wins) ? wins : []; const max = window.LW_MAX_ROWS; const reportsPower = load && load.reports_power === true; const showMeasured = !!(runs && runs.enabled === true); /* byWin finns så snart adaptern läst en deferrable_plan (även tom). */ const byWin = plan && plan.byWin ? plan.byWin : null; const hasPlan = !!(plan && plan.days && Object.keys(plan.days).length); const upd = (i, patch) => onChange(list.map((w, k) => k === i ? { ...w, ...patch } : w)); const add = () => { if (list.length >= max) return; onChange(list.concat([window.lwNextFreeWindow(list)])); }; /* Minst ett fönster krävs (V6): backend avvisar windows: []. Sista raden går därför inte att ta bort — knappen finns inte, i stället för att finnas och ge 422. */ const del = (i) => { if (list.length <= 1) return; onChange(lwPin(list).filter((_, k) => k !== i)); }; const [ordMap, setOrd] = useLwWinOrder(); const view = lwViewOrder(load, list, ordMap); const canMove = !!(load && load.id != null) && list.every(w => typeof w.wid === 'string'); const move = (vi, dir) => { const vj = vi + dir; if (!canMove || vj < 0 || vj >= view.length) return; const v = view.slice(); const t = v[vi]; v[vi] = v[vj]; v[vj] = t; const cur = (window.uiPrefs && window.uiPrefs.get('winOrder', {})) || {}; setOrd({ ...(lwOrdOk(cur) ? cur : {}), [load.id]: v.map(k => list[k].wid) }); }; const [names, setName] = useLwWinNames(); return (
{view.map((i, vi) => { const w = list[i]; const nk = lwNameKey(load, w); const lbl = typeof w.name === 'string' ? w.name : (nk ? names[nk] || '' : ''); return ( = 0 ? byWin[lwSrcIdx(w, i)] || null : null} hasPlan={hasPlan} onMove={(dir) => move(vi, dir)} nameKey={lwNameKey(load, w)} label={lbl} onRename={(t) => { const v = window.lwWinNameClean(t); if (v !== lbl) upd(i, { name: v }); }} onChange={(p) => upd(i, p)} onRemove={() => del(i)}/> ); })} {list.length < max ? ( ) : (
Max {max} fönster
)}
); } Object.assign(window, { GcWindowRow, GcWindowList, useLwWinNames, lwNameKey, lwSrcIdx, useLwWinOrder, lwViewOrder, lwWithLegacyNames, lwForgetLegacyNames });