// api.jsx — Lightweight fetch client + React hooks for /api/*. // // Conventions: // - All requests use credentials: 'same-origin' (the session cookie). // - Errors are normalized to { code, message, details? } regardless of source // (HTTP failure, JSON parse error, server-side ok=false envelope). // - A 401 response broadcasts a "lstl:unauthenticated" CustomEvent so the // app shell can show the login screen without polling /api/auth/session. const API = {}; async function _fetchJson(path, init = {}) { let resp; try { resp = await fetch(path, { credentials: 'same-origin', headers: { Accept: 'application/json' }, ...init, }); } catch (e) { return { ok: false, error: { code: 'NETWORK_ERROR', message: String(e && e.message || e) } }; } if (resp.status === 401) { try { window.dispatchEvent(new CustomEvent('lstl:unauthenticated')); } catch (_) {} return { ok: false, error: { code: 'AUTH_REQUIRED', message: '인증이 필요합니다' } }; } let body = null; try { body = await resp.json(); } catch (e) { return { ok: false, error: { code: 'BAD_JSON', message: 'invalid JSON response', status: resp.status } }; } if (!resp.ok) { const err = (body && body.error) || { code: 'HTTP_' + resp.status, message: resp.statusText }; return { ok: false, error: err }; } // Server-side envelope: if body has explicit `ok=false`, surface it. if (body && body.ok === false) { return { ok: false, error: body.error || { code: 'UNKNOWN', message: '알 수 없는 오류' }, raw: body }; } return { ok: true, data: body }; } function _qs(params) { if (!params) return ''; const u = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue; u.set(k, String(v)); } const s = u.toString(); return s ? '?' + s : ''; } // ─── public surface ──────────────────────────────────────────────────────── API.health = () => _fetchJson('/api/health'); API.auth = { session: () => _fetchJson('/api/auth/session'), login: (id, password) => _fetchJson('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ id, password }), }), logout: () => _fetchJson('/api/auth/logout', { method: 'POST' }), }; API.portfolio = { summary: () => _fetchJson('/api/portfolio/summary'), positions: () => _fetchJson('/api/portfolio/positions'), }; API.goals = { list: () => _fetchJson('/api/goals'), // config: { market, pct?, target_equity?, anchor_equity?, reset_anchor? } // POST /api/goals/config — operator-only; Codex never calls this. configSet: (body) => _fetchJson('/api/goals/config', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }), }; API.problems = { list: (params = {}) => _fetchJson('/api/problems' + _qs(params)), }; API.memory = { read: (params) => _fetchJson('/api/memory/read' + _qs(params)), stocks: () => _fetchJson('/api/memory/stocks'), }; API.news = { market: (params = {}) => _fetchJson('/api/news/market' + _qs(params)), stock: (params) => _fetchJson('/api/news/stock' + _qs(params)), search: (params) => _fetchJson('/api/news/search' + _qs(params)), get: (news_id) => _fetchJson('/api/news/get' + _qs({ news_id })), reads: (params = {}) => _fetchJson('/api/news/reads' + _qs(params)), }; API.backtests = { list: (params = {}) => _fetchJson('/api/backtests' + _qs(params)), get: (month) => _fetchJson('/api/backtests/' + encodeURIComponent(month)), }; API.orders = { intents: (params = {}) => _fetchJson('/api/orders/intents' + _qs(params)), reservations: (market) => _fetchJson('/api/orders/reservations' + _qs({ market })), }; API.cycles = { list: (params = {}) => _fetchJson('/api/cycles/list' + _qs(params)), timeline: (cycle_id) => _fetchJson('/api/cycles/' + encodeURIComponent(cycle_id) + '/timeline'), }; API.schedule = { read: () => _fetchJson('/api/schedule'), }; API.system = { state: () => _fetchJson('/api/system/state'), codexUsage: () => _fetchJson('/api/system/codex-usage'), }; API.fx = { rates: () => _fetchJson('/api/fx/rates'), }; window.API = API; // ─── React hooks (deps: React must already be loaded) ────────────────────── const { useState: _useApiState, useEffect: _useApiEffect, useCallback: _useApiCallback, useRef: _useApiRef } = React; /** * useApi(fetcher, deps, opts) — runs fetcher() on mount + dep change. * Returns { data, error, loading, reload }. * opts.refreshMs (optional): auto-reload interval. * opts.skip (optional bool): skip the initial fetch. */ function useApi(fetcher, deps = [], opts = {}) { const [state, setState] = _useApiState({ data: null, error: null, loading: !opts.skip }); const aliveRef = _useApiRef(true); const fetcherRef = _useApiRef(fetcher); fetcherRef.current = fetcher; const run = _useApiCallback(async () => { setState(s => ({ ...s, loading: true })); const res = await fetcherRef.current(); if (!aliveRef.current) return; if (res.ok) setState({ data: res.data, error: null, loading: false }); else setState({ data: null, error: res.error, loading: false }); }, []); _useApiEffect(() => { aliveRef.current = true; // opts.skip is in the dep list so a skip:true→false transition (e.g. a // lazy tab becoming active) actually re-triggers the fetch. Without it the // gated query would never run once unskipped. if (!opts.skip) run(); else setState(s => ({ ...s, loading: false })); return () => { aliveRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [opts.skip, ...deps]); _useApiEffect(() => { if (!opts.refreshMs || opts.skip) return; const id = setInterval(() => { run(); }, opts.refreshMs); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [opts.refreshMs, opts.skip, ...deps]); return { ...state, reload: run }; } window.useApi = useApi; // Small util for compact KRW / USD formatting reused by widgets. function fmtKRW(n) { if (n == null || isNaN(n)) return '—'; const v = Number(n); if (Math.abs(v) >= 100000000) return (v / 100000000).toFixed(2) + '억'; if (Math.abs(v) >= 10000) return (v / 10000).toFixed(1) + '만'; return Math.round(v).toLocaleString(); } function fmtUSD(n) { if (n == null || isNaN(n)) return '—'; const v = Number(n); return '$' + v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtPct(n, digits = 2) { if (n == null || isNaN(n)) return '—'; return Number(n).toFixed(digits) + '%'; } function fmtBytes(n) { if (n == null || isNaN(n)) return '—'; const v = Number(n); if (v >= 1e9) return (v / 1e9).toFixed(2) + ' GB'; if (v >= 1e6) return (v / 1e6).toFixed(1) + ' MB'; if (v >= 1e3) return (v / 1e3).toFixed(0) + ' KB'; return v + ' B'; } function fmtRelativeTime(isoUtc) { // Promotes anything < 1 minute to "1m" so the UI never shows raw seconds // (which look noisy on a ticking page, and read as nonsense for stamps // that are stale by 12s). Handles future timestamps too (Codex window // resets_at, scheduled jobs) — returns "in 2h", "in 7d" instead of the // old bug "-8284s ago". if (!isoUtc) return '—'; try { const t = new Date(isoUtc); if (isNaN(t.getTime())) return '—'; const diffSec = (Date.now() - t.getTime()) / 1000; const future = diffSec < 0; const abs = Math.abs(diffSec); let val, unit; if (abs < 60) { val = 1; unit = 'm'; } else if (abs < 3600) { val = Math.floor(abs / 60); unit = 'm'; } else if (abs < 86400) { val = Math.floor(abs / 3600); unit = 'h'; } else { val = Math.floor(abs / 86400); unit = 'd'; } return future ? `in ${val}${unit}` : `${val}${unit} ago`; } catch (_) { return '—'; } } window.fmtKRW = fmtKRW; window.fmtUSD = fmtUSD; window.fmtPct = fmtPct; window.fmtBytes = fmtBytes; window.fmtRelativeTime = fmtRelativeTime;