// home.jsx — Mobile-first home page (real data via /api/*). // 5 widgets: // ① 간단 포트폴리오 ② 보유 주식 가로 스크롤 // ③ 24h 스케줄 다이얼 ④ 알람 횟수 ⑤ 시스템 자원 const { useState: useHomeState, useMemo: useHomeMemo } = React; // Fallback rate used only if /api/fx/rates is unavailable. Picked to be // obviously not-current (low) so the operator notices in case of failure. const FX_FALLBACK_KRW_PER_USD = 1380; /* ─── data transforms ───────────────────────────────── */ function _num(s) { if (s == null) return 0; const v = parseFloat(s); return isNaN(v) ? 0 : v; } function buildPortfolioView(summary, fxRate, fxFresh) { if (!summary || summary.ok === false) { return { ok: false, total: 0, totalKRW: 0, kr: { krw: 0, pnl: 0 }, us: { usd: 0, krw: 0, pnl: 0 }, fxNote: null }; } const fx = fxRate || FX_FALLBACK_KRW_PER_USD; const krOk = summary.kr && summary.kr.ok !== false; const usOk = summary.us && summary.us.ok !== false; const krEquityKRW = krOk ? _num(summary.kr.summary?.total_equity_krw) : 0; const krPnl = krOk ? _num(summary.kr.summary?.unrealized_pnl_krw) : 0; const usEquityUSD = usOk ? _num(summary.us.summary?.total_equity_usd) : 0; const usPnlUSD = usOk ? _num(summary.us.summary?.unrealized_pnl_usd) : 0; const usEquityKRW = usEquityUSD * fx; const usPnlKRW = usPnlUSD * fx; const fxNote = fxFresh ? `$1 ≈ ${fx.toLocaleString(undefined, { maximumFractionDigits: 2 })}₩ (Toss 실시간)` : `$1 ≈ ${fx.toLocaleString(undefined, { maximumFractionDigits: 2 })}₩ (대체값)`; return { ok: krOk || usOk, krOk, usOk, total: krEquityKRW + usEquityKRW, kr: { krw: krEquityKRW, pnl: krPnl, ok: krOk, err: krOk ? null : summary.kr?.error }, us: { usd: usEquityUSD, krw: usEquityKRW, pnl: usPnlUSD, pnlKRW: usPnlKRW, ok: usOk, err: usOk ? null : summary.us?.error }, fxNote, }; } function buildHoldings(positions) { if (!positions || positions.ok === false) return []; const out = []; const list = (side) => { const inner = positions[side]; if (!inner || inner.ok === false) return; for (const p of inner.positions || []) { const qty = _num(p.qty); const avg = _num(p.avg_price); const cur = _num(p.current_price); const pct = _num(p.pnl_pct); out.push({ sym: p.symbol, name: p.name || p.symbol, market: p.market || (side === 'kr' ? 'KR' : 'NASDAQ'), qty, avg, cur, pct, marketValue: _num(p.market_value), currency: side === 'kr' ? 'KRW' : 'USD', }); } }; list('kr'); list('us'); return out; } function buildProblemView(problems) { if (!problems || problems.ok === false) return { counts: { CRITICAL: 0, ERROR: 0, WARN: 0, INFO: 0 }, latest: null, total: 0 }; const counts = { CRITICAL: 0, ERROR: 0, WARN: 0, INFO: 0 }; for (const it of problems.items || []) { const sev = (it.severity || '').toUpperCase(); if (counts[sev] != null) counts[sev]++; } const total = counts.CRITICAL + counts.ERROR + counts.WARN; const latest = (problems.items || []).find(x => ['CRITICAL', 'ERROR', 'WARN'].includes((x.severity || '').toUpperCase())); return { counts, total, latest }; } function buildResources(system) { if (!system || system.ok === false) return null; const r = system.resources || {}; const cpu = r.cpu || {}; const mem = r.memory || {}; const disk = system.disk || {}; return { cpu: { pct: cpu.percent, cores: cpu.cores, load1: cpu.load_1m }, memory: { pct: mem.used_pct, usedMB: mem.used_bytes ? mem.used_bytes / 1e6 : null, totalMB: mem.total_bytes ? mem.total_bytes / 1e6 : null }, disk: { pct: disk.data_used_pct, freeGB: disk.data_free_bytes ? disk.data_free_bytes / 1e9 : null, totalGB: disk.data_total_bytes ? disk.data_total_bytes / 1e9 : null }, uptimeSec: system.host?.uptime_seconds, processUptimeSec: system.host?.process_uptime_seconds, }; } function fmtUptime(seconds) { if (seconds == null || isNaN(seconds)) return '—'; const s = Math.floor(seconds); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } /* ─── cron next-occurrence (minimal, covers our 5 expressions) ─── */ function cronNextRun(expr, from) { if (!expr || expr === '@reboot') return null; const parts = expr.split(/\s+/).slice(0, 5); if (parts.length < 5) return null; const [mStr, hStr, domStr, monStr, dowStr] = parts; const m = parseInt(mStr, 10), h = parseInt(hStr, 10); if (isNaN(m) || isNaN(h)) return null; const dom = domStr === '*' ? null : parseInt(domStr, 10); let dowOk; if (dowStr === '*') dowOk = () => true; else if (dowStr.includes('-')) { const [a, b] = dowStr.split('-').map(x => parseInt(x, 10)); dowOk = (d) => d >= a && d <= b; } else { const v = parseInt(dowStr, 10); dowOk = (d) => d === v; } const start = new Date(from); for (let i = 0; i < 60; i++) { const cand = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + i, h, m, 0, 0)); if (cand <= from) continue; if (dom != null && cand.getUTCDate() !== dom) continue; if (!dowOk(cand.getUTCDay())) continue; return cand; } return null; } function scheduleKind(label) { if (!label) return 'other'; if (label.includes('KR close')) return 'kr_cycle'; if (label.includes('US close')) return 'us_cycle'; if (label.includes('KR open')) return 'kr_trade'; if (label.includes('US open')) return 'us_trade'; if (label.includes('backtest')) return 'backtest'; if (label.includes('Dashboard')) return 'dashboard'; return 'other'; } const KIND_COLOR = { kr_cycle: 'var(--m-kr)', us_cycle: 'var(--m-us)', kr_trade: 'var(--m-kr-worker)', us_trade: 'var(--accent)', backtest: 'var(--uncertain)', dashboard: 'var(--text-3)', other: 'var(--text-3)', }; function buildScheduleJobs(schedule, now) { if (!schedule || schedule.ok === false) return { jobs: [], next: null, total: 0 }; const jobs = []; for (const it of schedule.items || []) { const kind = scheduleKind(it.label); const next = cronNextRun(it.cron_expression, now); const lastT = it.last_run_utc ? new Date(it.last_run_utc) : null; jobs.push({ id: it.label, name: it.label, kind, color: KIND_COLOR[kind] || KIND_COLOR.other, lastT, next, cron: it.cron_expression, }); } jobs.sort((a, b) => (a.next ? +a.next : Infinity) - (b.next ? +b.next : Infinity)); const nextJob = jobs.find(j => j.next && j.next > now); // "Today" window: 36h forward from start of today const today = new Date(now); today.setUTCHours(0, 0, 0, 0); const cutoff = new Date(+today + 36 * 3600 * 1000); const inWindow = (t) => t && t >= today && t < cutoff; const dialJobs = []; for (const j of jobs) { if (inWindow(j.lastT)) dialJobs.push({ t: j.lastT, label: j.name + ' (완료)', color: j.color, key: j.id + '-last' }); if (inWindow(j.next)) dialJobs.push({ t: j.next, label: j.name, color: j.color, key: j.id + '-next' }); } dialJobs.sort((a, b) => +a.t - +b.t); return { jobs, dialJobs, next: nextJob, total: dialJobs.length }; } function relUntil(target, from) { if (!target) return '—'; const diff = (+target - +from) / 1000; if (diff < 0) return '경과'; if (diff < 60) return Math.floor(diff) + '초'; if (diff < 3600) return Math.floor(diff / 60) + '분'; if (diff < 86400) return Math.floor(diff / 3600) + '시간'; return Math.floor(diff / 86400) + '일'; } function fmtAbsKST(d) { if (!d) return '—'; try { return new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).format(d); } catch (_) { return d.toISOString().slice(5, 16); } } /* ─── ① Hero ───────────────────────────────────────── */ function Hero({ pview, view, onViewChange, onTap, now }) { if (!pview.ok) { return (