/* 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.
); } 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 */}
{soc}%mål {target}%
Smartladdning
03:00–05:30
Sparar
27 kr ikväll
); }; /* ---- 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
NU
{curMeta.label}
{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 (
+0 kr
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 */} {showDetails && (
Nedbrytning
{/* Internal cost section — collapsed by default */} {(opportunity > 0 || cycle > 0) && ( <> {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 (
dag 1 dag {days}
); }; /* ---- 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'}
{DEVICES.map((d,i)=>(
{d.name}
{d.state}
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 (

Aktivitet

idag
{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 (
{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 */}
Effektgräns
setLimit(parseFloat(e.target.value))} style={{flex:1, accentColor:'var(--blue)'}}/> {limit.toFixed(1).replace('.',',')} kWh/h
{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')}
); })}
); }