/* modules.jsx — supporting dashboard modules */
/* ---- Battery card ---- */
const BatteryCard = ({ soc, action, planNext, secondBattery, soc2, home, batt1, batt2, batteries }) => {
const cap = home?.batteryCapacityKwh || 13.5;
const chargeKw = home?.batteryChargeKw || 5;
const eff = home?.batteryEfficiency || 94;
// Live power (kW): positive = charging, negative = discharging.
const powerKw = batt1?.power_kw;
const chargingActions = new Set(['ladda_sol','ladda_nat']);
const dischargingActions = new Set(['tack_huset','peak','salj_sol','salj_batteri']);
// Prefer live power direction; fall back to action vocabulary
const state = powerKw != null
? (powerKw > 0.05 ? 'charging' : powerKw < -0.05 ? 'discharging' : 'idle')
: (chargingActions.has(action) ? 'charging'
: dischargingActions.has(action) ? 'discharging' : 'idle');
const arc = (pct) => {
const r = 78, cx=92, cy=92;
const a0 = -210, a1 = 30; // sweep from -210° to 30° (240°)
const a = a0 + (a1-a0) * (pct/100);
const rad = (deg) => (deg*Math.PI/180);
const p = (deg) => [cx + r*Math.cos(rad(deg)), cy + r*Math.sin(rad(deg))];
const [x0,y0] = p(a0);
const [xE,yE] = p(a);
const [xR,yR] = p(a1);
const large = (a - a0) > 180 ? 1 : 0;
const largeR = 1;
return {
track: `M ${x0} ${y0} A ${r} ${r} 0 ${largeR} 1 ${xR} ${yR}`,
fill: `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${xE} ${yE}`,
};
};
const a = arc(soc);
return (
Batteri
{state==='charging'?'Laddar':state==='discharging'?'Levererar':'Inaktiv'}
{Math.round(soc)}%
SOC
0 ? '+' : powerKw < 0 ? '−' : ''}${Math.abs(powerKw).toFixed(1).replace('.',',')} kW`
: (state==='charging'?'+2,5 kW':state==='discharging'?'-3,1 kW':'0 kW')}
c={state==='charging'?'var(--c-sell)':state==='discharging'?'var(--c-cover)':null}/>
Nästa: {planNext}
{/* Sekundära batterier — dynamiskt, stödjer N batterier (2, 3, …) */}
{(() => {
const list = Array.isArray(batteries) ? batteries.slice(1) : (batt2 ? [batt2] : []);
if (!list.length && !secondBattery) return null;
const fallback = (!list.length && secondBattery) ? [{ label:'Batteri 2', soc: soc2, ai_controlled:true, capacity_kwh:null }] : list;
return fallback.map((b, idx) => {
const ai = !!b.ai_controlled;
const accent = ai ? '#C4ADFF' : '#3FCC8B';
const tint = ai ? 'rgba(157,123,255,' : 'rgba(63,204,139,';
const label = b.label || `Batteri ${idx+2}`;
return (
{label}
{ai && EXTERN AI }
{b.capacity_kwh != null ? `${b.capacity_kwh.toFixed(1).replace('.',',')} kWh` : '—'}
{ai ? ' · ej HEMS-styrt' : ''}
{b.soc != null ? Math.round(b.soc) : '—'}%
);
});
})()}
);
};
const Row = ({k, v, c}) => (
{k}
{v}
);
/* ---- Bil & Laddbox (sammanslagen) ---- */
const EVCard = ({ laddbox, evCharging, cur }) => {
const soc = 64, target = 80;
const power = evCharging ? 7.0 : 0;
const state = !laddbox ? 'no-laddbox'
: evCharging ? 'charging'
: 'connected';
if (!laddbox){
return (
Bil & laddbox
Inte ansluten
Ingen laddbox registrerad. Anslut en Easee, Charge Amps eller annan kompatibel laddbox
för smartladdning, peak shaving och prisstyrning.
+ Lägg till laddbox
);
}
return (
Bil & laddbox
{state==='charging' ? 'Laddar' : 'Ansluten · väntar'}
{/* Car ↔ Laddbox visualization */}
ID.4 GTX
{soc}% · mål {target}%
{state==='charging' && (<>
>)}
Easee Home
{power>0 ? power.toFixed(1).replace('.',',')+' kW' : 'vilande · 11 kW max'}
{/* SOC bar */}
Smartladdning
03:00–05:30
);
};
/* ---- Next actions list (replaces old PlanStrip) ---- */
const PlanStrip = ({ slots, nowIdx, now }) => {
// Walk forward from nowIdx and collect upcoming action transitions.
// Also compute remaining time within the current action.
const cur = slots[nowIdx];
const minutesIntoQ = now ? (now.getMinutes() % 15) : 0;
const upcoming = [];
let i = nowIdx + 1;
while (i < slots.length && upcoming.length < 4){
if (slots[i].action !== (upcoming.length === 0 ? cur.action : upcoming[upcoming.length-1].action)){
// find end of this new run
let end = i+1;
while (end < slots.length && slots[end].action === slots[i].action) end++;
upcoming.push({ start:i, end, action:slots[i].action });
i = end;
} else i++;
}
const timeFromNow = (q) => {
const minutes = (q - nowIdx) * 15 - minutesIntoQ;
if (minutes < 60) return `om ${minutes}\u00A0min`;
const h = Math.floor(minutes/60), m = minutes % 60;
return m === 0 ? `om ${h}\u00A0h` : `om ${h}\u00A0h ${m}\u00A0min`;
};
// current action remaining
let curEnd = nowIdx+1;
while (curEnd < slots.length && slots[curEnd].action === cur.action) curEnd++;
const curRemMin = (curEnd - nowIdx) * 15 - minutesIntoQ;
const curMeta = ACTION_META[cur.action];
return (
Nästa åtgärder
enligt HEMS-plan
Spar idag: +47 kr
{curRemMin >= 60
? `${Math.floor(curRemMin/60)}\u00A0h ${curRemMin%60}\u00A0min kvar`
: `${curRemMin}\u00A0min kvar`}
{upcoming.map((r,idx)=>{
const meta = ACTION_META[r.action];
const dur = ((r.end-r.start)*15)/60;
return (
{meta.label}
{fmt.hhmm(r.start)}–{fmt.hhmm(r.end)} · {dur.toFixed(1).replace('.',',')} h
{timeFromNow(r.start)}
);
})}
{upcoming.length === 0 && (
Inga fler ändringar planerade idag.
)}
);
};
/* ---- Savings panel ---- */
const SavingsCard = ({ liveSavings }) => {
const monthNames = ['Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec'];
const t = liveSavings?.total || {};
const daysRunning = t.days ?? 0;
const startDate = t.start_date;
const discharge = t.discharge_value_kr ?? 0;
const charge = t.charge_cost_kr ?? 0;
const opportunity = t.opportunity_cost_kr ?? 0; // may be missing on cold start
const cycle = t.cycle_cost_kr ?? 0; // may be missing on cold start
const netProfit = t.net_profit_kr ?? 0;
const apiAvg = t.avg_profit_per_day_kr;
let co2Avoided = t.co2_avoided_kg;
// SPEC: practical savings = discharge − charge. Round source values first so
// headline equals breakdown rows visually (no off-by-one rounding diff).
const dischargeR = Math.round(discharge);
const chargeR = Math.round(charge);
const practical = dischargeR - chargeR;
const avgPerDay = daysRunning > 0 ? practical / daysRunning : (apiAvg ?? 0);
// Monthly list — practical per month for consistency with headline.
const rawMonthly = Array.isArray(liveSavings?.monthly) ? liveSavings.monthly : [];
const sorted = [...rawMonthly].sort((a, b) => String(a.month).localeCompare(String(b.month)));
const months = sorted.map((m, i, a) => {
const monthIdx = parseInt(String(m.month).slice(-2)) - 1;
const practicalMonth = (m.discharge_value_kr ?? 0) - (m.charge_cost_kr ?? 0);
return {
m: monthNames[monthIdx] || m.month,
ym: m.month,
days: m.days ?? 0,
v: practicalMonth,
partial: i === a.length - 1,
};
});
// Derive CO₂ if backend doesn't provide it (~3 kg/kr Swedish-grid heuristic).
if (co2Avoided == null && practical > 0) co2Avoided = practical * 3;
// Friendly date label
let startLabel = '';
if (startDate){
const d = new Date(startDate);
if (!isNaN(d)) startLabel = `Sedan ${d.getDate()} ${monthNames[d.getMonth()].toLowerCase()} · ${daysRunning} dagar`;
} else if (daysRunning > 0){
startLabel = `${daysRunning} dagars drift`;
}
const [showDetails, setShowDetails] = React.useState(false);
const [showInternal, setShowInternal] = React.useState(false);
// ── Cold-start state ────────────────────────────────────────────────
if (daysRunning === 0 || (practical === 0 && months.length === 0)){
return (
Besparing visas här när HEMS samlat data. Vanligen efter 3–7 dagars drift.
);
}
// ── Preliminary (< 7 days) ──────────────────────────────────────────
const preliminary = daysRunning > 0 && daysRunning < 7;
return (
{/* Headline — practical savings */}
= 0 ? 'var(--c-sell)' : 'var(--c-peak)'}}>
{practical>=0?'+':''}{fmt.kr(Math.round(practical))} kr
{!preliminary && avgPerDay > 0 && (
ø {avgPerDay.toFixed(1).replace('.',',')} kr/dag
)}
{preliminary && (
preliminär · {daysRunning} dagars data
)}
Praktisk besparing{startLabel ? ` · ${startLabel}` : ''}
{/* Monthly chart (≥ 2 months) */}
{months.length >= 2 && (() => {
const max = Math.max(1, ...months.map(m => Math.abs(m.v)));
// A partial month with essentially no accrued savings yet (e.g. the 1st
// of the month) gets a clearer "just startad" treatment instead of a
// confusing flat dashed nub that reads as "missing".
const isFresh = (m) => m.partial && Math.abs(m.v) < max * 0.04;
return (
{months.map((m,i)=>{
const fresh = isFresh(m);
return (
=0?'+':''}${fmt.kr(Math.round(m.v))} kr`}>
{fresh && (
ny månad
)}
{m.m}
);
})}
);
})()}
{/* Sparkline / cumulative growth for 1 month or sub-month data */}
{months.length < 2 && practical > 0 && (
)}
{/* Toggle to show breakdown */}
setShowDetails(s=>!s)}
style={{marginTop:14, width:'100%', justifyContent:'center', display:'flex', gap:6}}>
{showDetails ? 'Dölj detaljer' : 'Visa detaljer'}
{showDetails && (
Nedbrytning
{/* Internal cost section — collapsed by default */}
{(opportunity > 0 || cycle > 0) && (
<>
setShowInternal(s=>!s)}
title="Detta är HEMS interna optimeringsparametrar — inte direkta kostnader på din elräkning. Batterislitage är avskrivning på batteriet du redan äger. Offrad solförsäljning är teoretisk vinst som missades eftersom solen laddade batteriet istället för att säljas direkt."
style={{marginTop:12, padding:'6px 10px', width:'100%',
justifyContent:'flex-start', display:'flex', gap:6, alignItems:'center',
background:'rgba(157,123,255,.06)', borderColor:'rgba(157,123,255,.20)'}}>
Information om interna kostnader
{showInternal && (
)}
>
)}
)}
{/* CO₂ footer removed — origin of grid kWh isn't reliably known */}
);
};
const SavingsHeader = () => (
Besparingar
);
const BreakdownRow = ({label, value, kind, desc}) => {
// kind: 'profit' (green +), 'cost' (orange −), 'total' (white, bold).
const color = kind === 'profit' ? 'var(--c-sell)'
: kind === 'cost' ? 'var(--c-cover)'
: 'var(--ink)';
const bold = kind === 'total';
const sign = value > 0 ? '+' : value < 0 ? '−' : '';
const abs = Math.round(Math.abs(value));
const borderTop = kind === 'total'
? '1px solid var(--line)'
: 'none';
const paddingTop = kind === 'total' ? 6 : 3;
return (
{label}
{sign}{fmt.kr(abs)} kr
{desc && (
{desc}
)}
);
};
/* ── Cumulative-growth line used when only a few monthly entries exist ── */
const CumulativeGrowthChart = ({ days, total, avgPerDay }) => {
const W = 320, H = 80;
const padL = 4, padR = 4, padT = 6, padB = 14;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const N = Math.max(2, Math.min(days, 200));
const perDay = avgPerDay ?? total / days;
const pts = [];
for (let i = 0; i <= N; i++){
const t = i / N;
const ease = 0.5 - Math.cos(Math.PI * t) / 2;
const variance = (Math.sin(i * 0.6) + Math.sin(i * 0.21)) * perDay * 0.6;
const v = total * ease + variance * (1 - Math.abs(t - 0.5) * 2);
pts.push(Math.max(0, v));
}
const maxV = total * 1.05;
const x = (i) => padL + (i / N) * plotW;
const y = (v) => padT + plotH - (v / maxV) * plotH;
const line = pts.map((v, i) => `${i ? 'L' : 'M'} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ');
const area = line + ` L ${x(N).toFixed(1)} ${(padT+plotH).toFixed(1)} L ${x(0).toFixed(1)} ${(padT+plotH).toFixed(1)} Z`;
return (
);
};
/* ---- Device list ---- */
const DEVICES = [
{name:'Värmepump', icon:'thermo', state:'Auto · 21°C', kw:1.2, color:'var(--c-cover)'},
{name:'Bil (ID.4)', icon:'car', state:'Smartladdar 03:00', kw:0, color:'var(--blue)'},
{name:'Solpaneler', icon:'sun', state:'12 paneler · syd', kw:2.4, color:'var(--solar)'},
{name:'Batteri', icon:'battery',state:'Inaktiv · 68%', kw:0, color:'var(--battery)'},
{name:'Bastu', icon:'flash', state:'Schemalagd 19:00', kw:0, color:'var(--violet)'},
];
const DeviceList = ({ home }) => (
Enheter
{home?.solarArrays?.length ? `${home.solarArrays.length + 3} anslutna` : '5 anslutna'}
Hantera
{DEVICES.map((d,i)=>(
0?'var(--ink)':'var(--ink-3)'}}>
{d.kw>0 ? d.kw.toFixed(1).replace('.',',')+' kW' : '—'}
))}
);
/* ---- Activity log ---- */
const Activity = () => {
const items = [
{t:'10:42', txt:'Bytte till "Sälj sol" — överskott exporterades', c:'var(--c-sell)', a:'sell'},
{t:'09:15', txt:'Batteri fulladdat (98%)', c:'var(--battery)', a:'batt'},
{t:'07:30', txt:'Peak shaving aktiverat — sparade 4,80 kr', c:'var(--c-peak)', a:'peak'},
{t:'03:00', txt:'Bilen började ladda till 80%', c:'var(--blue)', a:'ev'},
{t:'01:45', txt:'Laddning från nät — kostnad 38 öre/kWh', c:'var(--c-charge-net)', a:'grid'},
];
return (
{items.map((it,i)=>(
{it.t}
{it.txt}
))}
);
};
Object.assign(window, { BatteryCard, EVCard, PlanStrip, SavingsCard, DeviceList, Activity, PeakShavingCard, WhyPanel, PlanWhy });
/* ---- "Därför ser dagen ut så här" (#60) ----
Renderar plan_explanation från /ml/analysis — en färdig, teknik-neutral
mening från backend som besvarar "varför säljer systemet min sol?".
Texten visas rakt av (ingen tolkning). Saknas/tomt fält → ingen ruta alls. */
function PlanWhy({ text, ctx }){
const hasText = text && typeof text === 'string' && text.trim();
// Beslutsunderlag-siffrorna (tidigare eget kort) bor nu här — en enda
// "varför + underlag"-yta i stället för två staplade öga-kort.
const chips = [];
if (ctx && Object.keys(ctx).length > 0){
const ore = (kr) => kr != null ? `${Math.round(kr*100)} öre` : '—';
const pad = (h) => String(h).padStart(2,'0');
if (ctx.current_buy_kr != null) chips.push(['Köp nu', ore(ctx.current_buy_kr)]);
if (ctx.current_sell_kr != null) chips.push(['Sälj nu', ore(ctx.current_sell_kr)]);
if (ctx.daily_avg_buy_kr != null) chips.push(['Snitt köp', ore(ctx.daily_avg_buy_kr)]);
if (ctx.daily_avg_sell_kr != null)chips.push(['Snitt sälj', ore(ctx.daily_avg_sell_kr)]);
if (ctx.min_future_buy_kr != null)chips.push(['Billigast', `${ore(ctx.min_future_buy_kr)}${ctx.min_future_buy_hour!=null?` kl ${pad(ctx.min_future_buy_hour)}`:''}`]);
if (ctx.max_future_sell_kr != null)chips.push(['Dyrast sälj', `${ore(ctx.max_future_sell_kr)}${ctx.max_future_sell_hour!=null?` kl ${pad(ctx.max_future_sell_hour)}`:''}`]);
}
if (!hasText && chips.length === 0) return null;
return (
Därför ser dagen ut så här
{hasText &&
{text.trim()}
}
{chips.length > 0 && (
{chips.map(([k,v],i)=>(
{k}
{v}
))}
)}
);
}
const __planWhyStyles = document.createElement('style');
__planWhyStyles.textContent = `
.plan-why{
display:flex; gap:12px; align-items:flex-start;
padding:14px 18px;
}
.plan-why .pw-ic{
flex:none; width:30px; height:30px; border-radius:8px;
display:inline-flex; align-items:center; justify-content:center;
color:var(--blue);
background:color-mix(in srgb, var(--blue) 10%, transparent);
border:1px solid color-mix(in srgb, var(--blue) 25%, transparent);
margin-top:1px;
}
.plan-why .pw-l{
font-size:10.5px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
color:var(--ink-3); margin-bottom:4px;
}
.plan-why .pw-t{
font-size:13px; line-height:1.55; color:var(--ink-1); text-wrap:pretty;
}
.plan-why .pw-chips{
display:flex; gap:8px; flex-wrap:wrap; margin-top:12px;
}
.plan-why .pw-chip{
display:inline-flex; align-items:baseline; gap:6px;
padding:6px 12px; border-radius:999px;
background:rgba(255,255,255,.025); border:1px solid var(--line);
}
.plan-why .pw-chip-k{ font-size:11px; color:var(--ink-3); }
.plan-why .pw-chip-v{ font-size:13px; font-weight:600; color:var(--ink); letter-spacing:-.01em; }
`;
document.head.appendChild(__planWhyStyles);
/* ---- "Varför?" panel — renders decision_context facts, no interpretation ---- */
function WhyPanel({ ctx }){
const [open, setOpen] = React.useState(false);
if (!ctx || Object.keys(ctx).length === 0) return null;
const kr = (v) => v == null ? '—' : v.toFixed(2).replace('.',',') + ' kr/kWh';
const tomorrowMissing = ctx.tomorrow_available === false;
const rows = [
{ k: 'Säljpris nu', v: kr(ctx.current_sell_kr) },
{ k: 'Dygnssnitt sälj', v: kr(ctx.daily_avg_sell_kr) },
{ k: 'Köppris nu', v: kr(ctx.current_buy_kr) },
{ k: 'Dygnssnitt köp', v: kr(ctx.daily_avg_buy_kr) },
];
if (ctx.min_future_buy_kr != null){
rows.push({
k: 'Billigaste köp',
v: `${kr(ctx.min_future_buy_kr)} ${ctx.min_future_buy_day || ''} kl ${String(ctx.min_future_buy_hour).padStart(2,'0')}`,
future: true,
});
}
if (ctx.max_future_sell_kr != null){
rows.push({
k: 'Dyrast sälj',
v: `${kr(ctx.max_future_sell_kr)} ${ctx.max_future_sell_day || ''} kl ${String(ctx.max_future_sell_hour).padStart(2,'0')}`,
future: true,
});
}
if (ctx.soc_pct != null){
rows.push({ k: 'Batteri', v: `${Math.round(ctx.soc_pct)}%` });
}
return (
setOpen(o=>!o)} aria-expanded={open}>
Varför?
{open && (
{rows.map((r,i)=>(
{r.k}{r.future && tomorrowMissing ? ' *' : ''}
{r.v}
))}
{tomorrowMissing && (
* morgondagens priser saknas (publiceras ca 13:00)
)}
)}
);
}
const __whyStyles = document.createElement('style');
__whyStyles.textContent = `
.why-panel{ margin-top: 8px; }
.why-toggle{
display: inline-flex; align-items: center; gap: 6px;
font: inherit; font-size: 12px; font-weight: 500;
color: var(--ink-2); cursor: pointer;
background: rgba(255,255,255,.02);
border: 1px solid var(--line);
border-radius: 8px; padding: 6px 11px;
transition: background .15s, color .15s, border-color .15s;
}
.why-toggle:hover{ background: rgba(91,168,255,.06); color: var(--ink); border-color: rgba(91,168,255,.25); }
.why-toggle .why-chev{ transition: transform .2s; }
.why-body{
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 14px;
background: rgba(255,255,255,.015);
}
.why-row{
display: flex; justify-content: space-between; gap: 14px;
padding: 5px 0; font-size: 12.5px;
border-top: 1px solid var(--line);
}
.why-row:first-child{ border-top: 0; }
.why-row .k{ color: var(--ink-2); }
.why-row .v{ color: var(--ink); font-weight: 500; }
.why-note{ margin-top: 8px; font-size: 11px; color: var(--ink-3); font-style: italic; }
`;
document.head.appendChild(__whyStyles);
/* ---- Peak shaving card ----
Shows: current-hour grid consumption (kWh), projected hour mean (kWh/h),
the user-set peak limit (kWh/h), and what the system is doing about it.
ENHETEN ÄR kWh/h, INTE kW (André 3/9): effektavgiften mäter energi över en
timme, inte momentan effekt. Korta effektspikar är ofarliga så länge
timsumman håller. */
function PeakShavingCard({ now, cur, slots, limit, setLimit, soc, scenario, livePeak }){
// Build "this hour so far" by summing grid draw of the 4 quarters of the current hour.
const hour = now.getHours();
const minuteOfHour = now.getMinutes();
const fracHour = (minuteOfHour) / 60;
const hourSlots = slots.slice(hour*4, hour*4 + 4);
// Grid draw per quarter — derived from action: any quarter that pulls from grid
// contributes home + (any battery charging from grid).
const gridPerSlot = (s) => {
if (s.action === 'ladda_nat') return s.consumption + 2.5;
if (s.action === 'nollexport' || s.action === 'vanta' || s.action === 'observera')
return Math.max(0, s.consumption - s.solar);
if (s.action === 'tack_huset' || s.action === 'peak') return 0; // battery covers
return 0;
};
// Already-consumed this hour (kWh) = sum of completed quarters + fraction of current
const currentQ = Math.min(3, Math.floor(minuteOfHour/15));
let consumed = 0;
for (let q=0; q 0.001) consumed = live.accumulated_kwh;
if ((live.grid_avg_kw ?? 0) > 0.001) projected = live.grid_avg_kw;
}
const pctOfLimit = Math.min(120, (projected / limit) * 100);
const status = projected > limit ? 'over'
: projected > limit*0.85 ? 'near'
: 'safe';
const statusMeta = {
safe: { l:'Inom gräns', c:'var(--c-sell)' },
near: { l:'Närmar sig', c:'var(--c-unchanged)' },
over: { l:'Peak shaving', c:'var(--c-peak)' },
}[status];
const peakActive = cur.action === 'peak' || status === 'over';
return (
Peak shaving
{statusMeta.l}
{/* Headline numbers */}
Hittills · {String(hour).padStart(2,'0')}:00–{String(hour).padStart(2,'0')}:59
{consumed.toFixed(2).replace('.',',')} kWh
Timsnitt · prognos
{projected.toFixed(2).replace('.',',')} kWh/h
{/* Bar with limit marker */}
{/* threshold zones */}
{/* projected fill */}
{/* consumed (so far) — darker overlay on same bar */}
{/* limit marker */}
0 kWh/h
Gräns {limit.toFixed(1).replace('.',',')} kWh/h
{/* Limit setter */}
{peakActive
? <> Batteri täcker överuttag · sparar effekttariff>
: status==='near'
? <> Övervakar — batteri kan ta över om gränsen nås>
: <> God marginal till effektgränsen>
}
{/* Månadens topp — den sätter effektavgiften. "timsnitt" i etiketten:
gränsen och topparna är ENERGI PER TIMME (kWh/h); korta effektspikar
är ofarliga så länge timsumman håller (backend 31/8). Pågående timme
som egen DÄMPAD rad, preliminär — aldrig inbakad i toppen
(mellanvärdet visade 8,1 där sanna toppen var 6,05). */}
{livePeak?.monthly_peak?.max_hourly_avg_kw != null && (
Högsta topp i månaden · timsnitt
{livePeak.monthly_peak.max_hourly_avg_kw.toFixed(2).replace('.',',')} kWh/h
{livePeak.monthly_peak.timestamp ?
{new Date(livePeak.monthly_peak.timestamp).toLocaleDateString('sv-SE',{day:'numeric',month:'short'})}
: null}
{livePeak.monthly_peak.current_hour_avg_kw != null && (
Pågående timme
{livePeak.monthly_peak.current_hour_avg_kw.toFixed(2).replace('.',',')} kWh/h (preliminärt)
)}
)}
{/* Mini history — last 4 hours grid mean */}
Senaste 6 timmar · snitteffekt
{[...Array(6)].map((_,i)=>{
const h = (hour - 5 + i + 24) % 24;
const hs = slots.slice(h*4, h*4+4);
let avg = 0; for (const s of hs) avg += gridPerSlot(s)*0.25;
const pct = Math.min(1.05, avg / limit);
const isOver = pct >= 1;
const isCur = i === 5;
return (
{String(h).padStart(2,'0')}
);
})}
);
}