/* verification.jsx — #59 "Facit": prognos vs verkligt utfall för en vald dag.
Backend: GET /api/verification?date=YYYY-MM-DD (default idag, max 14 dagar
bakåt — dagväljaren speglar samma gräns). Timmar utan data (framtid idag,
datalucka) = null → renderas som LUCKA i graferna, aldrig som 0.
Träffsäkerhet visas i mänskliga termer ("Prognosen träffade inom X%"),
aldrig som rå WAPE/MAE-jargong. < 6 timmar data → "för lite data för betyg".
Vad systemet GJORDE hämtas från befintliga /api/quarter-history?date=...
(samma payload som klockschemat) och renderas som ett 24h-band.
Faller tillbaka till deterministisk demodata om endpointen saknas
(mock-läge/prototyp) — markeras då med "demodata"-tagg. */
// ---- helpers ----------------------------------------------------------
function vfDateStr(d){
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
function vfDayLabel(dateStr){
const today = window.hemsDate(); today.setHours(0,0,0,0);
const d = new Date(dateStr + 'T00:00:00');
const diff = Math.round((today - d) / 86400000);
const days = ['sön','mån','tis','ons','tor','fre','lör'];
const months = ['jan','feb','mars','apr','maj','juni','juli','aug','sep','okt','nov','dec'];
const base = `${days[d.getDay()]} ${d.getDate()} ${months[d.getMonth()]}`;
if (diff === 0) return `Idag · ${base}`;
if (diff === 1) return `Igår · ${base}`;
return base;
}
// Deterministisk demodata (prototyp/mock) — seedad på datumet så samma dag
// alltid ser likadan ut. INTE en beräkning, bara illustration.
function vfMakeMockVerification(dateStr){
let s = 7;
for (const c of dateStr) s = (s * 31 + c.charCodeAt(0)) >>> 0;
const rnd = () => {
s |= 0; s = s + 0x6D2B79F5 | 0;
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const isToday = dateStr === vfDateStr(window.hemsDate());
const nowHour = window.hemsDate().getHours();
const cloudBase = rnd() * 70;
const hours = [];
let sf=0, sa=0, cf=0, ca=0, n=0, maeS=0, maeC=0;
for (let h = 0; h < 24; h++){
const hasData = !isToday || h < nowHour;
if (!hasData){
hours.push({ hour:h, solar_forecast_kwh:null, solar_actual_kwh:null,
cons_forecast_kwh:null, cons_actual_kwh:null, cloud_pct:null });
continue;
}
const bell = Math.exp(-Math.pow((h - 13) / 3.2, 2));
const cloud = Math.max(0, Math.min(100, cloudBase + (rnd() - 0.5) * 40));
const solF = +(bell * 2.6).toFixed(2);
const solA = +Math.max(0, bell * 2.6 * (1.1 - cloud/100*0.45) + (rnd()-0.5)*0.25).toFixed(2);
const cF = +(0.4 + Math.exp(-Math.pow((h-7.8)/1.1,2))*0.9
+ Math.exp(-Math.pow((h-19)/1.4,2))*1.2 + rnd()*0.1).toFixed(2);
const cA = +Math.max(0.1, cF + (rnd()-0.5)*0.45).toFixed(2);
hours.push({ hour:h, solar_forecast_kwh:solF, solar_actual_kwh:solA,
cons_forecast_kwh:cF, cons_actual_kwh:cA, cloud_pct: Math.round(cloud) });
sf+=solF; sa+=solA; cf+=cF; ca+=cA;
maeS+=Math.abs(solA-solF); maeC+=Math.abs(cA-cF); n++;
}
const wape = (mae, act) => act > 0.5 ? +((mae/act)*100).toFixed(1) : 0;
return {
date: dateStr, hours,
summary: {
solar: { forecast_kwh:+sf.toFixed(1), actual_kwh:+sa.toFixed(1),
mae_kwh:+(n?maeS/n:0).toFixed(2), wape_pct:wape(maeS, sa), hours_with_data:n },
consumption: { forecast_kwh:+cf.toFixed(1), actual_kwh:+ca.toFixed(1),
mae_kwh:+(n?maeC/n:0).toFixed(2), wape_pct:wape(maeC, ca), hours_with_data:n },
},
_mock: true,
};
}
// Träffsäkerhet på vanlig svenska — ALDRIG "WAPE"/"MAE" i UI:t.
function vfVerdict(sum){
if (!sum || sum.hours_with_data == null || sum.hours_with_data < 6){
return { txt: 'För lite data för betyg ännu', tone: 'dim', word: null };
}
const w = Math.max(0, Math.round(sum.wape_pct ?? 0));
const word = w <= 15 ? 'Mycket bra' : w <= 30 ? 'Bra' : 'Sådär';
const tone = w <= 15 ? 'good' : w <= 30 ? 'ok' : 'meh';
return { txt: `Prognosen träffade inom ${w}%`, tone, word };
}
const VF_TONE = {
good: 'var(--c-sell)',
ok: 'var(--c-charge-sol)',
meh: 'var(--c-cover)',
dim: 'var(--ink-3)',
};
// ---- jämförelsegraf: prognos (streckad) vs utfall (heldragen) ---------
function VfCompareChart({ hours, fKey, aKey, color, label, icon, showClouds, uid }){
const W = 520, H = 170;
const padL = 34, padR = 10, padT = showClouds ? 16 : 10, padB = 20;
const plotW = W - padL - padR, plotH = H - padT - padB;
const vals = [];
for (const h of hours){
if (h[fKey] != null) vals.push(h[fKey]);
if (h[aKey] != null) vals.push(h[aKey]);
}
const maxV = Math.max(0.5, ...vals) * 1.12;
const x = (h) => padL + ((h + 0.5) / 24) * plotW;
const y = (v) => padT + plotH - (v / maxV) * plotH;
// Bygg linje-segment som BRYTS vid null-timmar (lucka, inte 0)
const segments = (key) => {
const segs = []; let cur = null;
hours.forEach((h) => {
const v = h[key];
if (v == null){ cur = null; return; }
if (!cur){ cur = []; segs.push(cur); }
cur.push([x(h.hour), y(v)]);
});
return segs.filter(s => s.length >= 1);
};
const toPath = (pts) => pts.map((p, i) => `${i ? 'L' : 'M'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
const fSegs = segments(fKey);
const aSegs = segments(aKey);
// Null-zoner (datalucka / framtid) — skuggas svagt så luckan syns
const nullRects = [];
let runStart = null;
for (let h = 0; h <= 24; h++){
const isNull = h < 24 && hours[h] && hours[h][fKey] == null && hours[h][aKey] == null;
if (isNull && runStart == null) runStart = h;
if (!isNull && runStart != null){
nullRects.push([runStart, h]);
runStart = null;
}
}
const yTicks = [0, maxV/2, maxV];
return (
{label}
prognos
utfall
);
}
// ---- 24h-band: vad systemet GJORDE (quarter-history) ------------------
function VfActionBand({ qh }){
const quarters = qh?.quarters;
const hasAny = quarters && Object.keys(quarters).length > 0;
const mapA = window.hemsAdapter?.mapAction || ((a)=>a);
// Aggregera timmar per åtgärd till legenden
const tally = {};
if (hasAny){
for (const [, q] of Object.entries(quarters)){
const a = mapA(q.primary_action);
tally[a] = (tally[a] || 0) + 0.25;
}
}
const legend = Object.entries(tally)
.sort((a,b)=>b[1]-a[1]).slice(0,5);
return (
Vad systemet gjorde
skickade kommandon · per kvart
{!hasAny ? (
Ingen körhistorik för den här dagen.
) : (
<>
{Array.from({length:96}, (_, i) => {
const q = quarters[String(i)];
const a = q ? mapA(q.primary_action) : null;
const meta = a ? ACTION_META[a] : null;
return (
);
})}
0006121824
{legend.map(([a, h]) => (
{ACTION_META[a].label}
{h.toFixed(h % 1 ? 2 : 0).replace('.',',')} h
))}
>
)}
);
}
// ---- huvudkort --------------------------------------------------------
function VerificationCard(){
const MAX_BACK = 14; // speglar backendens gräns
const [offset, setOffset] = React.useState(0); // 0 = idag
const [state, setState] = React.useState({ loading:true, data:null, qh:null, mock:false });
const date = React.useMemo(() => {
const d = window.hemsDate(); d.setDate(d.getDate() - offset);
return vfDateStr(d);
}, [offset]);
React.useEffect(() => {
let cancelled = false;
setState(s => ({ ...s, loading: true }));
(async () => {
let data = null, mock = false;
try {
data = await window.hems.getVerification(date);
if (!data || !Array.isArray(data.hours)) throw new Error('tomt svar');
} catch (e){
data = vfMakeMockVerification(date);
mock = true;
}
let qh = null;
try { qh = await window.hems.getQuarterHistory(date); } catch(e){}
if (cancelled) return;
setState({ loading:false, data, qh, mock });
})();
return () => { cancelled = true; };
}, [date]);
const { loading, data, qh, mock } = state;
const sumS = data?.summary?.solar;
const sumC = data?.summary?.consumption;
const vS = vfVerdict(sumS);
const vC = vfVerdict(sumC);
const fkwh = (v) => v == null ? '—' : v.toFixed(1).replace('.',',');
return (
Facit — höll prognosen?
prognos mot uppmätt utfall
{mock && !loading && demodata}
{vfDayLabel(date)}
{loading ? (
) : (
{/* Betyg i mänskliga termer */}
{[['sun','Sol', sumS, vS, 'var(--solar)'], ['home','Förbrukning', sumC, vC, 'var(--home)']]
.map(([icon, lbl, sum, v, color]) => (
{lbl}
{v.word ? `${v.word} — ${v.txt.charAt(0).toLowerCase()}${v.txt.slice(1)}` : v.txt}
{sum && sum.hours_with_data >= 6 && (
Prognos {fkwh(sum.forecast_kwh)} kWh · Utfall {fkwh(sum.actual_kwh)} kWh
)}
))}
)}
);
}
// ---- styles ------------------------------------------------------------
const __vfStyles = document.createElement('style');
__vfStyles.textContent = `
.vf-card .head{ flex-wrap: wrap; row-gap: 8px; }
.vf-body{ display: grid; gap: 16px; padding: 14px 18px 18px; }
.vf-picker{ display: inline-flex; align-items: center; gap: 8px; }
.vf-nav{
width: 28px; height: 28px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
font: 600 16px/1 Geist; color: var(--ink-1); cursor: pointer;
background: rgba(255,255,255,.03); border: 1px solid var(--line);
transition: background .15s, border-color .15s;
}
.vf-nav:hover:not(:disabled){ background: rgba(91,168,255,.08); border-color: rgba(91,168,255,.3); }
.vf-nav:disabled{ opacity: .3; cursor: default; }
.vf-date{ font-size: 12.5px; font-weight: 500; color: var(--ink); min-width: 116px; text-align: center; }
.vf-mock-tag{
font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
color: var(--ink-3); border: 1px dashed var(--line-2);
border-radius: 5px; padding: 2px 7px; margin-right: 4px;
}
.vf-verdicts{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 760px){ .vf-verdicts{ grid-template-columns: 1fr; } }
.vf-verdict{
border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
background: rgba(255,255,255,.015);
}
.vf-verdict-l{
display: flex; align-items: center; gap: 7px;
font-size: 10.5px; font-weight: 600; letter-spacing: .07em;
text-transform: uppercase; color: var(--ink-3);
}
.vf-verdict-t{ margin-top: 7px; font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; }
.vf-verdict-s{ margin-top: 5px; font-size: 11px; color: var(--ink-2); }
.vf-chart-head{
display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; flex-wrap: wrap;
}
.vf-chart-t{
display: inline-flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 600; color: var(--ink);
}
.vf-sub{ font-size: 11px; color: var(--ink-3); }
.vf-legend{ margin-left: auto; display: inline-flex; gap: 14px; font-size: 10.5px; color: var(--ink-2); }
.vf-legend > span{ display: inline-flex; align-items: center; gap: 6px; }
.vf-line{ display: inline-block; width: 16px; }
.vf-line.dash{ border-top: 2px dashed; opacity: .7; }
.vf-line.solid{ height: 2px; border-radius: 2px; }
.vf-svg{ display: block; width: 100%; height: auto; }
.vf-band-wrap{ }
.vf-band{
display: flex; height: 26px; border-radius: 7px; overflow: hidden;
border: 1px solid var(--line); background: rgba(255,255,255,.015);
}
.vf-cell{ flex: 1; }
.vf-band-axis{
display: flex; justify-content: space-between;
margin-top: 5px; font-size: 9.5px; color: var(--ink-3);
}
.vf-band-legend{
margin-top: 9px; display: flex; flex-wrap: wrap; gap: 8px 16px;
font-size: 11px; color: var(--ink-2);
}
.vf-band-legend > span{ display: inline-flex; align-items: center; gap: 6px; }
.vf-band-legend .dot{ width: 8px; height: 8px; border-radius: 3px; display: inline-block; }
.vf-band-legend b{ color: var(--ink-1); font-weight: 500; margin-left: 2px; }
.vf-band-empty{ font-size: 12px; color: var(--ink-3); padding: 10px 0 2px; }
.vf-skel-row{ display: flex; gap: 12px; }
.vf-skel{
display: block; flex: 1; border-radius: 12px;
background: linear-gradient(100deg, rgba(180,200,240,.05) 35%, rgba(180,200,240,.11) 50%, rgba(180,200,240,.05) 65%);
background-size: 220% 100%;
animation: vfShimmer 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce){ .vf-skel{ animation: none; } }
@keyframes vfShimmer{ from{ background-position: 120% 0; } to{ background-position: -80% 0; } }
`;
document.head.appendChild(__vfStyles);
Object.assign(window, { VerificationCard });