/* hems-adapter.jsx — Transforms HEMS API responses into the prototype's internal slot format and live snapshot. Designed so toggling live ↔ mock is a one-flag flip. Slot format used by the prototype: { i, day, hour, buy, sell, solar, consumption, actualSolar, actualConsumption, action, soc } */ // ---- Action mapping: HEMS → prototype vocabulary ---- const HEMS_ACTION_MAP = { chargesolar: 'ladda_sol', charge: 'ladda_nat', // pure grid-charge chargegrid: 'ladda_nat', // legacy alias selfconsumption: 'tack_huset', sellsolar: 'salj_sol', export: 'salj_batteri', // battery dumped to grid at extreme prices peakshaving: 'peak', pause: 'vanta', idle: 'vanta', wait: 'vanta', zeroexport: 'nollexport', nollexport: 'nollexport', observe: 'ai_styrt', observera: 'ai_styrt', unchanged: 'oforandrat', }; function mapAction(a){ return HEMS_ACTION_MAP[a] || a || 'vanta'; } // ---- Build the 192-slot schedule from /ml/analysis + /api/history ---- function buildSlots(mlAnalysis, history, settings){ const decisions = mlAnalysis.quarterly_decisions || []; // Sol-prognos-källa (Lennie-fix 2026-05-31): föredra LP-planens // `lp_solar_kwh_per_hour` (48 element: 0..23 idag, 24..47 imorgon). Det är // EXAKT samma bias-korrigerade array som Homey läser via // solar_forecast_kwh_today → dashboard och Homey visar då identiska tal. // Fallback till rå ML-prognos (solar_pattern.hourly_prediction) bara om // LP-planen saknas (cold-start / ingen färsk plan). const lpSolar = mlAnalysis.solar_pattern?.lp_solar_kwh_per_hour; const lpSolarOk = Array.isArray(lpSolar) && lpSolar.length >= 24; const solarToday = lpSolarOk ? lpSolar.slice(0, 24) : (mlAnalysis.solar_pattern?.hourly_prediction || mlAnalysis.predictions?.solar?.hourly || []); const solarTomorrow = lpSolarOk && lpSolar.length >= 48 ? lpSolar.slice(24, 48) : (mlAnalysis.solar_pattern?.tomorrow_hourly || []); // Förbrukningskurvan + "Förbr.topp" ska spegla LP:ns DISPLAY-input // (consumption_kwh_per_hour = bas + planerade laster/EV), INTE basprognosen. // Bufferten (+12–13 %) är BORTA ur serien från och med BgLP-rundan 27/8 // (backend-beslut): prognosen visar förväntad förbrukning, inte en // marginal påslagen för planeringens skull. Parallellt med lp_solar_kwh_per_hour ovan. 48-el. array // (0..23 idag, 24..47 imorgon); fallback till consumption_pattern (ren bas) // vid cold-start. Bugg 2026-07-24: badge visade 3,1 kW men kurvan ~1,4 — // de drev isär för att kurvan ritade basen i stället för LP-inputen. const _lpCons = mlAnalysis.schedule_data?.consumption_kwh_per_hour || mlAnalysis.consumption_kwh_per_hour || mlAnalysis.consumption_pattern?.consumption_kwh_per_hour || mlAnalysis.consumption_pattern?.lp_consumption_kwh_per_hour; const _lpConsOk = Array.isArray(_lpCons) && _lpCons.length >= 24; const consToday = _lpConsOk ? _lpCons.slice(0, 24) : (mlAnalysis.consumption_pattern?.hourly_prediction || []); const consTomorrow = _lpConsOk && _lpCons.length >= 48 ? _lpCons.slice(24, 48) : (mlAnalysis.consumption_pattern?.tomorrow_hourly || []); // Fryst prognos "inför timmen" (SPEC solprognos-diff 2026-07): array[24], // kWh per klocktimme, skrivs aldrig om. null = ännu inte fryst (>~2h fram) // eller värde saknas (feature nyligen aktiverad / datastopp) → gap, inte nolla. // Endast idag (day 0). // Ärlig solprognos (SPEC 2026-08-26): solar_pattern.honest bär TRE serier — // morgonens frusna dygnsprognos (ändras aldrig intradag), levande prognos // UTAN utfalls-merge, uppmätt per timme (null = omätt, aldrig 0). Saknas // honest ⇒ exakt gammalt beteende. Punktlinjen föredrar DYGNSANKARET // framför gamla "inför timmen"-frysningen. const _honest = mlAnalysis.solar_pattern?.honest; const _h24 = (arr) => Array.isArray(arr) && arr.length >= 24 ? arr.slice(0, 24) : null; const frozenForecast = _h24(_honest?.frozen_day_hourly) || mlAnalysis.solar_pattern?.frozen_forecast_hourly; const frozenOk = Array.isArray(frozenForecast) && frozenForecast.length >= 24; // Last-resort typical-day pattern derived from history (used when consumption_pattern is empty). const consByHour = Array.from({length:24}, () => ({sum:0,n:0})); for (const row of (history?.consumption || [])){ const h = row.hour; if (h >= 0 && h < 24 && row.home_consumption != null){ consByHour[h].sum += row.home_consumption / 1000; consByHour[h].n++; } } const consPatternKw = consByHour.map(b => b.n > 0 ? b.sum/b.n : 1.0); // Actuals: index by date so we pick today's measured rows. const today = window.hemsToday(); const actualSolarByHour = Array.from({length:24}, ()=>null); const actualConsByHour = Array.from({length:24}, ()=>null); const actualSocByQuarter = {}; // quarter index 0..95 → soc % for (const row of (history?.consumption || [])){ if (row.date === today){ actualSolarByHour[row.hour] = (row.solar_power || 0) / 1000; actualConsByHour[row.hour] = (row.home_consumption || 0) / 1000; // Per-quarter SOC measurements (when present) if (Array.isArray(row.quarters)){ row.quarters.forEach((q, qi) => { if (typeof q?.soc === 'number'){ actualSocByQuarter[row.hour*4 + qi] = q.soc; } }); } } } const slots = decisions.map(d => { const day = d.day || 0; const localQ = d.local_quarter != null ? d.local_quarter : (d.hour*4 + Math.floor((d.minute||0)/15)); const globalI = day*96 + localQ; const hour = d.hour || 0; // Pick the right hourly array per day. const solarHourlyArr = day === 0 ? solarToday : solarTomorrow; const consHourlyArr = day === 0 ? consToday : consTomorrow; /* Saknad prognos gav 0 kW respektive 1,0 kW — påhittade tal som inte gick att skilja från riktiga. En nolla läses som "solen tog slut" (Lennie 18/8: kurvan dog medan prognosen låg på 4 kW). Talen ligger kvar för gamla designens skull, men VETSKAPEN följer med i *Known-flaggorna, och nya vyerna ritar hål i stället för nolla. Prognoserna är dessutom TIMVÄRDEN: samma tal i timmens fyra kvartar. Priset är per kvart. fcHourly bär den skillnaden till vyerna. */ const solarHas = solarHourlyArr[hour] != null; const consHas = consHourlyArr[hour] != null || consPatternKw[hour] != null; const solarKw = (solarHas ? solarHourlyArr[hour] : 0); const consKw = (consHourlyArr[hour] != null ? consHourlyArr[hour] : consPatternKw[hour] || 1.0); const actSolarKw = day === 0 ? (actualSolarByHour[hour] ?? solarKw) : solarKw; const actConsKw = day === 0 ? (actualConsByHour[hour] ?? consKw) : consKw; // Fryst prognos per klocktimme — bara idag, null bevaras (gap). const frozenSolar = (day === 0 && frozenOk && frozenForecast[hour] != null) ? frozenForecast[hour] : null; const plannedSoc = d.soc_after_pct ?? d.soc_after ?? null; const actSoc = day === 0 ? (actualSocByQuarter[localQ] ?? plannedSoc) : plannedSoc; return { i: globalI, day, hour, buy: ((d.buy_price_kr ?? d.buy_price) || 0) * 100, sell: ((d.sell_price_kr ?? d.sell_price) || 0) * 100, solar: solarKw, consumption: consKw, solarKnown: solarHas, consKnown: consHas, fcHourly: true, actualSolar: actSolarKw, frozenSolar, actualConsumption: actConsKw, action: mapAction(d.action), soc: plannedSoc, actualSoc: actSoc, reason: d.reason, charge_kwh: d.charge_kwh, discharge_kwh: d.discharge_kwh, grid_import_kwh: d.grid_import_kwh, grid_export_kwh: d.grid_export_kwh, solar_export_kwh: d.solar_export_kwh, }; }); // Fill any gaps so we always have 192 slots (defensive). if (slots.length < 192){ for (let i = slots.length; i < 192; i++){ slots.push({ i, day: Math.floor(i/96), hour: Math.floor((i%96)/4), buy: 100, sell: 30, solar: 0, consumption: 1.0, solarKnown: false, consKnown: false, fcHourly: true, actualSolar: 0, actualConsumption: 1.0, frozenSolar: null, action: 'vanta', soc: 50, }); } } // Sort by i in case API returns out of order slots.sort((a,b)=>a.i - b.i); let lastSoc = null; for (const s of slots){ // Imorgon (i>=96): om plan saknas kommer soc som null ELLER 0 → ärv senast kända. const missing = (s.soc == null) || (s.i >= 96 && (!s.soc || s.soc === 0)); if (missing) s.soc = lastSoc; else lastSoc = s.soc; if (s.actualSoc == null || (s.i >= 96 && !s.actualSoc)) s.actualSoc = lastSoc; } /* Signalera om s.consumption redan inkluderar planerade SKJUTBARA LASTER (LP display-input). Forecast-grafen använder detta för att undvika dubbelräkning när den rekonstruerar last-höjningen i mock/cold-start. (enhetlighet 2026-07-24) */ slots.consIncludesLoads = _lpConsOk; /* ELBILEN ÄR EN EGEN FRÅGA (3/9). Flaggan ovan lästes tidigare av forecast.jsx som om den betydde "innehåller lasterna OCH elbilen", men LP:s cons-serie innehåller aldrig elbilen: EV går in i solvern som ev_demand och injiceras aldrig i cons. Mätt hos user_72fe66e8da19 är cons_components.loads_add 0,000 i h24–h29 medan ev_plan laddar 1,84 kWh/h i exakt de timmarna. Att karva ut EV ur en kurva som inte innehåller den tog bandtoppen från 3,680 till 0,599 kW (−83,7 %) och Σ band från 9,784 till 1,078 kWh (−89,0 %) — elbilen försvann i praktiken ur Plan 24h och Prognos. Egen flagga, eget backendfält, ALLTID false i dag: fältet finns inte i svaret, så uttrycket blir false och beteendet är bit-identiskt med nuvarande. Börjar backend någon gång injicera EV i cons-serien räcker det att fältet sätts true. Toppnivå-först, samma regel som övriga nya fält (annars fastnar värdet i L2-cachen som EV-bandet gjorde 29/7). */ const _evInCons = ('cons_includes_ev' in (mlAnalysis || {})) ? mlAnalysis.cons_includes_ev : mlAnalysis?.consumption_pattern?.cons_includes_ev; slots.consIncludesEv = _lpConsOk && _evInCons === true; // Normaliserade honest-serier följer med slots-arrayen (samma mönster som // consIncludesLoads). Konsument: gc-prognoser via gravio-adapterns dagsarray. /* DATAKVALITET (backend 27/8, punkt B i fixpaketet). NightSolarGuard och balansvakten karantänerar pingar vars mätvärden är orimliga — de räknas bort ur historik, solfacit OCH prognosträff. Räknarna ligger per timdoc parallellt med v2x_skipped_pings. Vi summerar DAGENS timmar: markören förklarar varför utfallet kan vara lägre än mätaren utan att någon behöver misstänka ett räknefel. Noll ⇒ null ⇒ inget visas; en markör som alltid står där slutar betyda något. */ /* Datumval: dagens rader om de finns, annars historikens SENASTE dygn. Mock-samplet ligger på fasta majdatum och skarp historik kan släpa ett dygn — ett hårt today-filter gav då tyst null (verifieraren 29/8: markören renderades ingenstans). Är dygnet inte dagens tappar texten ordet "idag" i stället för att ljuga om när mätningarna hoppades över. */ const _dqRows = (history?.consumption || []).filter(r => typeof r.hour === 'number' && (typeof r.night_solar_skipped_pings === 'number' || typeof r.balance_skipped_pings === 'number')); /* Fallback-dygnet måste vara det senaste med FAKTISKA karantäner, inte bara det senaste dygn som bär fälten: sista dygnet i historiken är ofta ett halvt dygn utan de timmar där vakten slog till (mock-samplet slutar före nattimmarna), och då blev summan 0 ⇒ tyst null igen. */ const _dqPerDate = new Map(); for (const row of _dqRows){ const e = _dqPerDate.get(row.date) || { night: 0, balance: 0 }; e.night += row.night_solar_skipped_pings || 0; e.balance += row.balance_skipped_pings || 0; _dqPerDate.set(row.date, e); } const _dqToday = _dqPerDate.get(today); const _dqDate = (_dqToday && (_dqToday.night || _dqToday.balance)) ? today : [..._dqPerDate.entries()].filter(([, e]) => e.night || e.balance) .map(([dt]) => dt).sort().pop() || null; const _dqPick = _dqDate ? _dqPerDate.get(_dqDate) : null; const _dqNight = _dqPick && _dqPick.night > 0 ? _dqPick.night : null; const _dqBal = _dqPick && _dqPick.balance > 0 ? _dqPick.balance : null; slots.dataQuality = (_dqNight != null || _dqBal != null) ? { nightSolar: _dqNight, balance: _dqBal, isToday: _dqDate === today, date: _dqDate } : null; slots.solarHonest = _honest ? { frozen: _h24(_honest.frozen_day_hourly), pure: _h24(_honest.forecast_pure_hourly), actual: _h24(_honest.actual_hourly), accuracy: _honest.accuracy || null, } : null; return slots; } // ---- Pick "current quarter" snapshot from live + slots ---- // Merges live measurements onto the matching slot so the dashboard reflects real values. function buildLiveCur(live, slots, nowDate){ const now = nowDate || window.hemsDate(); const nowQ = Math.min(95, now.getHours()*4 + Math.floor(now.getMinutes()/15)); const slot = slots[nowQ] || slots[0]; // Per-battery state — preferred source for multi-battery (incl. AI-styrda) systems. // Fallback to top-level `soc` + `battery_power` ENBART om per_battery_actions // saknas helt (null/undefined). Tom array ([]) → rendera inga batterier // (vilket är ett backend-feltillstånd, men vi ska inte trumfa det med fake-data). const rawPerBatt = live?.per_battery_actions; const hasPerBatt = Array.isArray(rawPerBatt); // VIKTIGT (2026-06-01, Engehov multi-batteri-flöde): // `per_battery_actions[].power_kw` är HEMS *kommando-magnitud* — ALLTID // positiv (multi_battery.split fördelar `power_kw * rate/total_rate`, aldrig // negerad för urladdning) och ALLTID 0 för AI-batterier (HEMS observerar // bara, kommenderar inget). Den DUGER INTE som flödesriktning/effekt. // // Den FAKTISKA, signerade, uppmätta effekten kommer från växelriktaren via // `battery_power_list` (W, samma index som per_battery_actions): // > 0 = laddar IN, < 0 = laddar UT. // Vi använder den uppmätta effekten för flödet (riktning + magnitud) och // behåller HEMS-actionen enbart som etikett/styrningsinfo. För AI-batteriet // ger detta en synlig flödeslinje (Fel 1) och rätt riktning för urladdande // HEMS-batterier (Fel 2). const powerList = Array.isArray(live?.battery_power_list) ? live.battery_power_list : null; const measuredKw = (i) => { if (powerList && typeof powerList[i] === 'number') return powerList[i] / 1000; return null; }; // Normalised battery list — keep ALL of them, in the order returned. // Each entry: { soc, power_kw (signed, measured), ai_controlled, action, label } const batteries = hasPerBatt ? rawPerBatt.map((b, i) => { const mk = measuredKw(i); // Föredra uppmätt signerad effekt. Saknas listan: fall tillbaka till // HEMS-kommandot, signerat utifrån action (urladdning = negativ). const cmdKw = b?.power_kw ?? 0; const dischargeActions = new Set(['selfconsumption','export','peakshaving','discharge']); const signedCmdKw = dischargeActions.has(b?.action) ? -Math.abs(cmdKw) : Math.abs(cmdKw); return { soc: b?.soc ?? null, power_kw: mk != null ? mk : signedCmdKw, capacity_kwh: b?.capacity_kwh ?? b?.capacity ?? null, ai_controlled: !!b?.ai_controlled, action: b?.action ? mapAction(b.action) : null, // b.battery från backend är INDEX (1,2,3), inte label. Använd det // bara som suffix om vi har flera batterier. Singel → bara "Batteri". label: b?.ai_controlled ? `AI-batteri` : (rawPerBatt.length > 1 ? `Batteri ${i+1}` : 'Batteri'), }; }) : [{ soc: live?.soc != null ? live.soc : null, power_kw: (live?.battery_power || 0) / 1000, ai_controlled: false, action: live?.action ? mapAction(live.action) : null, label: 'Batteri', }]; // Convenience aliases for the older code path: first non-AI = b1, first AI = b2. const b1 = batteries.find(b => !b.ai_controlled) || null; const b2 = batteries.find(b => b.ai_controlled) || null; return { ...slot, // Override with live measurements (W → kW) solar: (live?.solar_power || 0) / 1000, consumption: (live?.home_consumption || 0) / 1000, actualSolar: (live?.solar_power || 0) / 1000, actualConsumption:(live?.home_consumption || 0) / 1000, soc: b1 ? b1.soc : (live?.soc != null ? live.soc : slot.soc), action: live?.action ? mapAction(live.action) : slot.action, // Signed power flows (kW). Positive convention: // battery_kw > 0 = laddar in, < 0 = laddar ut // grid_kw > 0 = importerar, < 0 = exporterar battery_kw: b1 ? (b1.power_kw ?? 0) : (live?.battery_power || 0) / 1000, grid_kw: (live?.grid_power || 0) / 1000, ev_kw: (live?.ev_power != null ? live.ev_power : (Array.isArray(live?.loads) ? live.loads.reduce((s,x) => s + (Number(x && x.power_w) || 0), 0) : 0)) / 1000, batt1: b1, batt2: b2, batteries, // full list (any N) // Useful extras _live: live, }; } // ---- Compute today's stats from live + slots ---- function buildTodayStats(live){ return { /* NULL-BEVARANDE (SPEC 2026-08-27): 0 kWh på natten är ett MÄTT värde, saknad live-data är inte. Samma regel som honest-serien: omätt ≠ 0. */ solarTodayKwh: live?.solar_today_kwh ?? null, /* ?? null även här (backend-ändring 27/8, HEMS_DAILY_TOTALS_NULLABLE: fältet blir JSON-null när Firestore-summeringen misslyckas — null får inte bli 0,0 kWh). Alla läsare guardar != null. */ homeTodayKwh: live?.home_today_kwh ?? null, }; } // ---- Map /api/peak-insights to the PeakShavingCard's shape ---- function buildPeakSnapshot(peak, settings){ const limit = peak?.peak_shaving?.max_grid_kw ?? settings?.peak_shaving_max_grid_kw ?? 5; return { limit, consumedKwh: peak?.current_hour?.accumulated_kwh || 0, minutesElapsed: peak?.current_hour?.minutes_elapsed || 0, gridAvgKw: peak?.current_hour?.grid_avg_kw || 0, isPeakNow: peak?.peak_shaving?.is_peak_now || false, /* Med HEMS_PEAK_TOP_EXCLUDE_CURRENT (backend 31/8) är detta alltid en AVSLUTAD timme — pågående timmens mellanvärde visade 8,1 där sanna toppen var 6,05. Pågående timme kommer separat nedan. */ monthlyPeakKw: peak?.monthly_peak?.max_hourly_avg_kw || null, /* kW eller null — pågående timmes löpande snitt, preliminärt tills timmen stängt. Egen dämpad rad i kortet, ALDRIG inbakad i toppen. */ currentHourAvgKw: peak?.monthly_peak?.current_hour_avg_kw ?? null, monthlyPeakTime: peak?.monthly_peak?.timestamp || null, socReservePct: peak?.peak_shaving?.soc_reserve?.percent || 0, }; } // ---- Home metadata from /settings/api ---- function buildHome(settings){ if (!settings) return null; const solarKwp = settings.solar_capacity_kwp ?? 0; const arrays = settings.solar_arrays || []; return { name: settings.name || 'Hem', area: settings.area || 'SE3', batteryCapacityKwh: settings.battery_capacity_kwh || 0, batteryChargeKw: settings.battery_charge_rate_kw || 0, batteryEfficiency: settings.battery_efficiency || 0, solarKwp, solarArrays: arrays, maxGridKw: settings.max_grid_power_kw || 0, mainFuseA: settings.main_fuse_a || 0, strategy: settings.strategy || 'economic', peakEnabled: settings.peak_shaving_enabled || false, peakLimitKw: settings.peak_shaving_max_grid_kw || 5, updateIntervalMin: settings.update_interval_minutes || 5, // OBS (2026-06-20): köp-/säljpriser visas RAKT AV från backend // (buy_price/sell_price/current_buy_kr är redan kompletta inkl. avgifter + // moms). Frontend ska ALDRIG addera nätavgift/energiskatt/moms på ett pris // — det ger dubbelräkning (köppris ~2× för högt). Därför exponeras INTE // grid_fee_kwh/energy_tax_kwh/vat_percent här; de hör bara hemma i // inställningsformuläret, inte i någon prisvisning. }; } // ---- Boot helper: configure hems facade + load the sample file as mock fallback ---- async function bootHems({ baseUrl, apiKey, useMock, sampleUrl }){ if (!window.hems){ console.error('hems.js not loaded'); return; } // Try to load mock sample (cheap + small enough) if (sampleUrl){ try{ const res = await fetch(sampleUrl + (sampleUrl.includes('?') ? '&' : '?') + '_t=' + Date.now(), { cache: 'no-store' }); if (res.ok){ const sample = await res.json(); window.hems.setMockData(sample); } } catch(e){ console.warn('mock sample load failed', e); } } // Mock-fallback behålls för LÄSVÄGEN (endpoints som saknas live mockas // medvetet). Skrivvägen skyddas i stället av __fromMock-märkningen i // hems.js — saveSettingsPatch vägrar posta ett mockat dokument. window.hems.config({ baseUrl, apiKey, useMock, mockFallback: true }); } // Per-kvart beslutssegment — EN delad källa för alla beslutsfärgade grafer // (prognosremsan i forecast.jsx OCH LIVE-staplarna i gravio-livechart.jsx), // så de aldrig kan driva isär igen (display-spec 2026-06-25). // • Framtid / ingen historik → ett segment med planens action (s.action). // • Förflutet med quarter-history actions[] → faktisk sub-kvart-mix // (flera beslut inom samma 15-min-kvart) i proportion. // quarterIdx = lokalt kvartindex 0..95. Returnerar [{action, proportion}]. // OBS: history-actions är RÅA api-koder; fallback är redan mappad (s.action). const QH_FLOOR = 0.10; // SPEC graf-sanning 2026-07-10: dåtid får ALDRIG visa något som inte skickats. // §1 Förfluten kvart utan dispatch-sanning → NEUTRAL ('okand'), aldrig planens // färg. Planen är framtid, inte facit. // §2 Gradient-golv: segment < 10 % absorberas av dominanten (datat är sant; // 1–3 %-slivers ljuger på pixelnivå när dispatch sker strax före kvartsgräns). // Använd proportion (tidsandel), aldrig count — carry-in har count 0. function quarterSegments(quarterIdx, isPast, qhRoot, plannedAction){ if (!isPast) return [{ action: plannedAction, proportion: 1 }]; const q = qhRoot && qhRoot.quarters ? qhRoot.quarters[String(quarterIdx)] : null; if (!q || !Array.isArray(q.actions) || q.actions.length === 0){ return [{ action: 'okand', proportion: 1 }]; // §1: neutralt, inte planen } const byAction = {}; for (const a of q.actions){ const p = a.proportion != null ? a.proportion : 1 / q.actions.length; if (p > 0) byAction[a.action] = (byAction[a.action] || 0) + p; } let segs = Object.keys(byAction).map(action => ({ action, proportion: byAction[action] })); if (segs.length === 0) return [{ action: 'okand', proportion: 1 }]; const norm = arr => { const t = arr.reduce((n,s)=>n+s.proportion,0) || 1; arr.forEach(s=>s.proportion/=t); return arr; }; norm(segs); if (segs.length > 1){ // §2: golva bort slivers <10% let kept = segs.filter(s => s.proportion >= QH_FLOOR); const dropped = segs.filter(s => s.proportion < QH_FLOOR); if (kept.length === 0) kept = [segs.reduce((a,b)=> b.proportion>a.proportion ? b : a)]; if (dropped.length){ const absorbed = dropped.reduce((n,s)=>n+s.proportion,0); const dom = kept.reduce((a,b)=> b.proportion>a.proportion ? b : a); dom.proportion += absorbed; // dominanten sväljer slivers } segs = norm(kept); } return segs; } // Expose Object.assign(window, { hemsAdapter: { buildSlots, buildLiveCur, buildTodayStats, buildPeakSnapshot, buildHome, mapAction, quarterSegments, bootHems } });