/* ============================================================================ MyTraderOS — /app SPA shell (Wave 0 · L0 MVP) React inline + Babel standalone (no build step) · vendor local (ห้าม CDN) โครง: Router (History API) · SessionGuard (mock) · AppShell (TabBar+sidebar+header) · states กลาง reusable · i18n loader · เชื่อม mock API layer เนื้อหน้าจริงมาใน wave ถัดไป — ทุก route = placeholder ที่ใช้ states กลางได้จริง ============================================================================ */ const { useState, useEffect, useRef, useCallback, useLayoutEffect } = React; /* ---------------------------------------------------------------- i18n ---- */ function flatten(obj, prefix, out) { out = out || {}; prefix = prefix || ""; for (const k in obj) { const v = obj[k], key = prefix ? prefix + "." + k : k; if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); else out[key] = v; } return out; } function getLang() { try { return localStorage.getItem("mtos_lang") || "th"; } catch (e) { return "th"; } } async function loadCatalog(lang) { const res = await fetch("i18n/" + lang + ".json", { cache: "no-store" }); if (!res.ok) throw new Error("catalog " + lang + " " + res.status); return flatten(await res.json()); } /* --------------------------------------------------------- fmt helpers ---- */ /* แสดงผลเท่านั้น — ค่ามาจาก API (client ห้ามคำนวณเลขเทรด) */ function fmtMoney(cents, currency) { if (cents === null || cents === undefined) return "—"; const sym = currency === "THB" ? "฿" : "$"; const v = cents / 100; const sign = v > 0 ? "+" : v < 0 ? "−" : ""; const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return sign + sym + abs; } function fmtPct(x) { return x === null || x === undefined ? "—" : (x * 100).toFixed(1) + "%"; } function fmtNum(x, d) { return x === null || x === undefined ? "—" : Number(x).toFixed(d === undefined ? 2 : d); } function fmtTime(iso) { if (!iso) return "—"; try { return new Date(iso).toLocaleString("th-TH", { timeZone: "Asia/Bangkok", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); } catch (e) { return iso; } } /* ------------------------------------------------------------- router ---- */ /* [vision-demo] hash routing — mount ได้ทุก path (เสิร์ฟใต้ /share) · refresh ไม่ 404 · แก้เฉพาะ shim ไม่แตะ logic */ function __vPath() { var h = window.location.hash.replace(/^#/, ""); var p = h.split("?")[0]; return p && p.charAt(0) === "/" ? p : "/"; } function __vSearch() { var h = window.location.hash.replace(/^#/, ""); var i = h.indexOf("?"); return i === -1 ? "" : h.slice(i); } function navigate(to, replace) { if (replace) history.replaceState({}, "", "#" + to); else history.pushState({}, "", "#" + to); window.dispatchEvent(new Event("mtos:nav")); } function useRoute() { const [loc, setLoc] = useState(__vPath() + __vSearch()); useEffect(() => { const on = () => setLoc(__vPath() + __vSearch()); window.addEventListener("popstate", on); window.addEventListener("mtos:nav", on); return () => { window.removeEventListener("popstate", on); window.removeEventListener("mtos:nav", on); }; }, []); return loc; } /* breakpoint hook (desktop ≥1024px) — ใช้เลือก behavior calendar (day card vs sheet) */ function useIsDesktop() { const mq = () => (typeof window.matchMedia === "function" ? window.matchMedia("(min-width:1024px)").matches : window.innerWidth >= 1024); const [d, setD] = useState(mq()); useEffect(() => { const on = () => setD(mq()); let m; if (typeof window.matchMedia === "function") { m = window.matchMedia("(min-width:1024px)"); (m.addEventListener ? m.addEventListener("change", on) : m.addListener(on)); } window.addEventListener("resize", on); return () => { if (m) { m.removeEventListener ? m.removeEventListener("change", on) : m.removeListener(on); } window.removeEventListener("resize", on); }; }, []); return d; } /* ------------------------------------------------- query + date helpers -- */ function getQuery() { return new URLSearchParams(__vSearch()); } function patchQuery(patch, replace) { const sp = new URLSearchParams(__vSearch()); for (const k in patch) { const v = patch[k]; if (v === null || v === undefined || v === "") sp.delete(k); else sp.set(k, v); } const qs = sp.toString(); navigate(__vPath() + (qs ? "?" + qs : ""), replace); } function pad2(n) { return String(n).padStart(2, "0"); } function ymd(d) { return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); } function ymNow() { const n = new Date(); return n.getFullYear() + "-" + pad2(n.getMonth() + 1); } function parseYMD(s) { if (!/^\d{4}-\d{2}-\d{2}$/.test(s || "")) return null; const p = s.split("-").map(Number); const d = new Date(p[0], p[1] - 1, p[2]); if (d.getFullYear() !== p[0] || d.getMonth() !== p[1] - 1 || d.getDate() !== p[2]) return null; // 02-30 ฯลฯ return d; } function parseYM(s) { if (!/^\d{4}-\d{2}$/.test(s || "")) return null; const p = s.split("-").map(Number); if (p[1] < 1 || p[1] > 12) return null; return { y: p[0], m: p[1] }; } /* เงินย่อ (heatmap/calendar cell) — แสดงผลเท่านั้น ค่าจาก API */ function fmtMoneyShort(cents, currency) { if (cents === null || cents === undefined) return "—"; const sym = currency === "THB" ? "฿" : "$"; const v = cents / 100, a = Math.abs(v); const sign = v > 0 ? "+" : v < 0 ? "−" : ""; const s = a >= 1000 ? (a / 1000).toFixed(a >= 10000 ? 0 : 1) + "k" : a.toFixed(0); return sign + sym + s; } const ROUTES = [ { re: /^\/$/, name: "landing", pub: true }, { re: /^\/login$/, name: "login", pub: true }, { re: /^\/register$/, name: "register", pub: true }, { re: /^\/app\/onboarding$/, name: "onboarding" }, { re: /^\/app$/, name: "dashboard" }, { re: /^\/app\/calendar$/, name: "calendar" }, { re: /^\/app\/trades$/, name: "trades" }, { re: /^\/app\/trades\/([^/]+)$/, name: "trade_detail", keys: ["id"] }, { re: /^\/app\/insights$/, name: "insights" }, { re: /^\/app\/import$/, name: "import" }, { re: /^\/app\/connect$/, name: "connect" }, { re: /^\/app\/unlock$/, name: "unlock" }, { re: /^\/app\/settings$/, name: "settings" }, { re: /^\/app\/vision\/([^/]+)$/, name: "vision", keys: ["key"] }, ]; function matchRoute(pathname) { for (const r of ROUTES) { const m = pathname.match(r.re); if (m) { const params = {}; (r.keys || []).forEach((k, i) => { params[k] = m[i + 1]; }); return { name: r.name, pub: !!r.pub, params: params }; } } return { name: "not_found", pub: false, params: {} }; } /* -------------------------------------------------------- mock session --- */ const Session = { backend() { try { return !!(window.MTOS_API && window.MTOS_API.config && window.MTOS_API.config.base); } catch (e) { return false; } }, active() { try { return localStorage.getItem("mtos_session") === "1"; } catch (e) { return false; } }, login() { try { localStorage.setItem("mtos_session", "1"); localStorage.setItem("mtos_token", "mock.jwt.token"); } catch (e) {} }, _store(d) { try { localStorage.setItem("mtos_session", "1"); if (d.access_token) localStorage.setItem("mtos_token", d.access_token); if (d.refresh_token) localStorage.setItem("mtos_refresh", d.refresh_token); if (d.user) localStorage.setItem("mtos_user", JSON.stringify(d.user)); } catch (e) {} }, _errCode(e) { return (e && e.envelope && e.envelope.error && e.envelope.error.code) || "error"; }, async loginWith(email, pw) { if (!this.backend()) { this.login(); return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/login", { email: email, password: pw }); this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, async registerWith(body) { if (!this.backend()) { this.login(); return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/register", body); this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, async startDemo() { if (!this.backend()) { try { localStorage.setItem("mtos_session", "1"); localStorage.setItem("mtos_demo", "1"); localStorage.setItem("mtos_token", "mock.jwt.token"); } catch (e) {} return { ok: true }; } try { var r = await window.MTOS_API.post("/auth/demo", {}); try { localStorage.setItem("mtos_demo", "1"); } catch (e) {} this._store(r.data); return { ok: true }; } catch (e) { return { ok: false, code: this._errCode(e) }; } }, isDemo() { try { return localStorage.getItem("mtos_demo") === "1"; } catch (e) { return false; } }, logout() { try { ["mtos_session", "mtos_token", "mtos_refresh", "mtos_demo", "mtos_user"].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) {} }, }; /* dev-only UI toggle (state switcher ฯลฯ) — เปิดด้วย localStorage mtos_dev=1 */ function isDevMode() { try { return localStorage.getItem("mtos_dev") === "1"; } catch (e) { return false; } } /* =========================================================================== Central state components (reuse ทุกหน้า — ห้ามหน้าไหนประดิษฐ์เอง) =========================================================================== */ function Skeleton({ variant }) { const rows = variant === "tiles" ? 4 : variant === "list" ? 5 : 3; const box = { background: "linear-gradient(90deg,var(--surface-2),var(--surface-3),var(--surface-2))", backgroundSize: "200% 100%", animation: "mtos-shimmer 1.2s linear infinite", borderRadius: "var(--radius)" }; if (variant === "tiles") { return
{Array.from({ length: 4 }).map((_, i) =>
)}
; } return
{Array.from({ length: rows }).map((_, i) =>
)}
; } function EmptyState({ t, title, body, cta, onCta }) { return
{title || t("states.empty_title")}
{body || t("states.empty_body")}
{onCta && }
; } function ErrorState({ t, code, onRetry }) { return
{t("states.error_title")}
{t("states.error_repeat", { code: code || "—" })}
; } function FirstSyncProgress({ t, minutes, progress }) { const pct = Math.round((progress || 0) * 100); return
{t("states.first_sync_title")}
{t("states.first_sync_eta", { minutes: minutes || 5 })}
; } function LockedSampleCard({ t, children }) { return
{t("common.sample")}
{t("states.locked_sample_hint")}
{children}
; } function Toast({ msg, onDone }) { useEffect(() => { if (!msg) return; const id = setTimeout(onDone, 3000); return () => clearTimeout(id); }, [msg]); if (!msg) return null; return
{msg}
; } function BottomSheet({ open, title, onClose, children }) { useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open]); if (!open) return null; return
{title &&
{title}
} {children}
; } function FAB({ onClick, label }) { return ; } /* --------------------------------------------------- small UI helpers ---- */ function MoneyText({ cents, currency }) { const v = cents === null || cents === undefined ? 0 : cents; const color = v > 0 ? "var(--profit)" : v < 0 ? "var(--loss)" : "var(--ink)"; return {fmtMoney(cents, currency)}; } function SyncWatermark({ t, meta }) { if (!meta || !meta.synced_through) return null; return
{t("states.watermark", { time: fmtTime(meta.synced_through) })}
; } /* ธง "ตัวอย่าง" (locked/sample state) — มุมขวาบนการ์ด */ function SampleBadge({ t }) { return {t("common.sample")}; } /* dropdown เล็ก reuse ทั้ง account + range chip ตำแหน่งเมนู = fixed + clamp ให้อยู่ในกรอบ viewport เสมอ (กันตกขอบซ้าย/ขวาบนมือถือ) desktop คงพฤติกรรมเดิม (ชิดขวา trigger) · มือถือ chip ชิดซ้าย -> fallback ชิดซ้าย trigger + clamp */ function Dropdown({ label, dot, open, onToggle, children }) { const btnRef = useRef(null); const menuRef = useRef(null); const [pos, setPos] = useState(null); useLayoutEffect(() => { if (!open) { setPos(null); return; } const compute = () => { const btn = btnRef.current, menu = menuRef.current; if (!btn) return; const b = btn.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight, pad = 8; const mw = menu ? menu.offsetWidth : 180; const maxW = vw - pad * 2; let left = b.right - Math.min(mw, maxW); // default: ชิดขวา trigger (คงพฤติกรรม desktop เดิม) if (left < pad) left = b.left; // ล้นซ้าย -> ชิดซ้าย trigger (เคส chip มือถือ) if (left + mw > vw - pad) left = vw - pad - mw; // clamp ขวา if (left < pad) left = pad; // clamp ซ้าย setPos({ top: b.bottom + 6, left, maxW, maxH: vh - b.bottom - pad * 2 }); }; compute(); window.addEventListener("resize", compute); window.addEventListener("scroll", compute, true); return () => { window.removeEventListener("resize", compute); window.removeEventListener("scroll", compute, true); }; }, [open]); return
{open &&
{children}
}
; } function MenuItem({ selected, onClick, children }) { return ; } /* generic user glyph (avatar) — ไม่มีรูปจริง (demo/normal) วาดด้วย SVG บนพื้น gradient ม่วง */ function UserGlyph({ size }) { const s = size || 20; return ; } /* AccountMenu — avatar ขวาบน header → dropdown: ตั้งค่า + ออกจากระบบ (แทน TH/EN + logout icon เดิม) */ function AccountMenu({ t, onLogout }) { const [open, setOpen] = useState(false); const isDemo = Session.isDemo(); const goSettings = () => { setOpen(false); navigate("/app/settings"); }; const doLogout = () => { setOpen(false); onLogout(); }; const row = { display: "flex", alignItems: "center", gap: 10, width: "100%", textAlign: "left", minHeight: 44, padding: "0 10px", border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", background: "transparent", color: "var(--ink-dim)", fontWeight: 600, fontSize: "var(--fs-caption)" }; return
{open &&
setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: "var(--z-dropdown)" }} />
{isDemo ? t("menu.demo_user") : t("menu.user")}
{isDemo &&
{t("demo.badge_suffix")}
}
}
; } /* AccountFilterChip — เปลี่ยน scope บัญชี -> เขียน URL ?account_id= -> หน้า re-fetch */ function AccountFilterChip({ t }) { const [accts, setAccts] = useState(null); const [open, setOpen] = useState(false); const sel = getQuery().get("account_id") || ""; useEffect(() => { window.MTOS_API.get("/accounts").then((r) => setAccts(r.data)).catch(() => setAccts([])); }, []); const cur = (accts || []).find((a) => a.acc_id === sel); const label = sel && cur ? cur.nickname : t("filters.account_all"); const pick = (id) => { setOpen(false); patchQuery({ account_id: id || null }); }; return setOpen((o) => !o)}> pick("")}>{t("filters.account_all")} {(accts || []).map((a) => pick(a.acc_id)}>{a.nickname} · {a.broker} ••{a.last4})} ; } /* DateRangeChip — เปลี่ยนช่วงวัน -> URL ?range= -> re-fetch (default all = รวมทุกวัน = ไม่มี param) */ function DateRangeChip({ t }) { const [open, setOpen] = useState(false); const sel = getQuery().get("range") || "all"; const opts = [["7d", t("filters.range_7d")], ["30d", t("filters.range_30d")], ["90d", t("filters.range_90d")], ["all", t("filters.range_all")]]; const cur = opts.find((o) => o[0] === sel) || opts[3]; const pick = (v) => { setOpen(false); patchQuery({ range: v === "all" ? null : v }); }; return setOpen((o) => !o)}> {opts.map((o) => pick(o[0])}>{o[1]})} ; } /* filter row บนหน้า (มือถือ — desktop มี chip บน header แล้ว) */ function PageFilterBar({ t }) { return
; } /* =========================================================================== Nav config + Shell =========================================================================== */ const NAV_GROUPS = (t) => [ { label: t("app.group.record"), items: [ { name: "dashboard", to: "/app", label: t("app.nav.dashboard"), icon: "▦" }, { name: "trades", to: "/app/trades", label: t("app.nav.trades"), icon: "≣" }, { name: "import", to: "/app/import", label: t("app.nav.import"), icon: "⇪" }, ] }, { label: t("app.group.analyze"), items: [ { name: "insights", to: "/app/insights", label: "อินไซต์ AI", icon: "◎" }, { name: "vision/tilt", to: "/app/vision/tilt", label: "Tiltmeter", icon: "◉", vision: true }, { name: "vision/ranking", to: "/app/vision/ranking", label: "Ranking", icon: "↗", vision: true }, ] }, { label: "คอมมูนิตี้", items: [ { name: "vision/leaderboard", to: "/app/vision/leaderboard", label: "Leaderboard", icon: "♛", vision: true }, { name: "vision/verified", to: "/app/vision/verified", label: "Verified Badge", icon: "✓", vision: true }, { name: "vision/tools", to: "/app/vision/tools", label: "รีวิว EA / Tools", icon: "⚙", vision: true }, { name: "vision/broker", to: "/app/vision/broker", label: "Broker Review", icon: "★", vision: true }, ] }, { label: "เครื่องมือ", items: [ { name: "vision/calendar", to: "/app/vision/calendar", label: "ปฏิทินข่าว", icon: "▤", vision: true }, { name: "vision/notify", to: "/app/vision/notify", label: "แจ้งเตือน LINE / TG", icon: "◈", vision: true }, ] }, ]; const TABS = (t) => [ { name: "dashboard", to: "/app", label: t("app.nav.dashboard"), icon: "▦" }, { name: "trades", to: "/app/trades", label: t("app.nav.trades"), icon: "≣" }, { name: "__fab" }, { name: "import", to: "/app/import", label: t("app.nav.import"), icon: "⇪" }, { name: "settings", to: "/app/settings", label: t("app.nav.settings"), icon: "⚙" }, ]; function Logo() { return
M
MyTraderOS
; } function LangToggle({ lang, onSet }) { const cell = (code) => ({ padding: "6px 12px", cursor: "pointer", fontWeight: 700, fontSize: 12, transition: "all var(--dur-fast)", color: lang === code ? "#fff" : "var(--ink-mute)", background: lang === code ? "var(--brand-grad)" : "transparent" }); return
onSet("th")}>TH onSet("en")}>EN
; } function AppShell({ t, active, lang, onSetLang, onLogout, onQuickAdd, children }) { const groups = NAV_GROUPS(t); const tabs = TABS(t); const go = (to) => (e) => { e.preventDefault(); navigate(to); }; const [drawer, setDrawer] = useState(false); const goD = (to) => (e) => { e.preventDefault(); setDrawer(false); navigate(to); }; return
{/* header (chrome มืด — mtos-chrome ตั้ง token subtree ให้ลูกทุกตัวเป็นโทนมืด) */}
{ ◆ VISION DEMO · ข้อมูลจำลอง }
{drawer &&
setDrawer(false)} style={{ position: "fixed", inset: 0, background: "var(--scrim)", zIndex: "calc(var(--z-nav) + 1)" }} />} {drawer && }
{/* sidebar desktop */} {/* content */}
{children}
{/* FAB desktop (bottom-right) */}
{/* tabbar mobile (light · ขาว + border + active ม่วง ตาม mockup บนพื้นสว่าง) */}
; } function SideItem({ it, active, brand, onClick }) { const color = active ? "var(--brand-strong)" : brand ? "var(--brand)" : "var(--ink-dim)"; return {it.icon} {it.label} {it.vision && } ; } /* =========================================================================== Page scaffold + state demo (ทุก route ใช้ได้จริง) =========================================================================== */ function StateSwitcher({ t, value, onChange }) { if (!isDevMode()) return null; // ซ่อนแถบ dev ในการใช้งานปกติ · เปิดด้วย localStorage mtos_dev=1 const opts = [ ["ready", t("demo.state_ready")], ["loading", t("demo.state_loading")], ["empty", t("demo.state_empty")], ["error", t("demo.state_error")], ["locked", t("demo.state_locked")], ]; return
{t("demo.state_switcher")}: {opts.map(([k, lbl]) => )}
; } function PageHeader({ t, page, action }) { return

{t("pages." + page + ".title")}

{t("pages." + page + ".subtitle")}
{action &&
{action}
}
; } /* generic placeholder page ที่ demo states กลางครบ */ function GenericPage({ t, page, toast }) { const [st, setSt] = useState("ready"); return
{st === "loading" && } {st === "empty" && toast(t("states.toast_saved"))} />} {st === "error" && setSt("ready")} />} {st === "locked" &&
} {st === "ready" &&
{t("demo.placeholder_note")}
{t("demo.central_states_note")}
}
; } /* =========================================================================== Dashboard cards (Wave 1) — StatTile · EquityCurve · MiniHeatmap · FeesPanel ทุกเลขมาจาก API · client format เท่านั้น · pf_flag/null render ตาม flag =========================================================================== */ function StatTile({ label, value, sub, onClick, sample, t }) { const clickable = !!onClick; return
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } } : undefined} style={{ position: "relative", cursor: clickable ? "pointer" : "default", minHeight: 92 }}> {sample && }
{label}
{value}
{sub &&
{sub}
}
; } /* สร้าง 5 tile จาก summary object (ใช้ทั้ง real + sample) */ function buildTiles(t, s) { const rExp = s.expectancy_r == null ? null : (s.expectancy_r >= 0 ? "+" : "−") + Math.abs(s.expectancy_r).toFixed(2) + "R"; return [ { key: "net", label: t("analytics.net_pnl"), value: , sub: t("analytics.trades_line", { count: s.trades_closed, wins: s.wins, losses: s.losses, be: s.breakeven }) }, { key: "wr", label: t("analytics.win_rate"), value: {fmtPct(s.win_rate)}, sub: t("analytics.win_rate_explain"), dim: "win_rate" }, { key: "pf", label: t("analytics.profit_factor"), value: {s.pf_flag === "no_losses" ? "∞" : s.pf_flag === "no_trades" ? "—" : fmtNum(s.profit_factor)}, dim: "profit_factor" }, { key: "exp", label: t("analytics.expectancy"), value: {t("analytics.per_trade")}, sub: rExp, dim: "expectancy" }, { key: "awl", label: t("analytics.avg_wl"), value: {fmtNum(s.payoff_ratio)}, sub: fmtMoney(s.avg_win_cents, s.currency) + " / " + fmtMoney(s.avg_loss_cents, s.currency), dim: "payoff" }, ]; } function StatTileGrid({ t, tiles, sample }) { return
{tiles.map((ti) => navigate("/app/insights?dimension=" + ti.dim) : undefined} />)}
; } /* --------------------------------------------------------------------------- Dashboard KPI cards (TradeZella-style · Wave dashkpi) 5 การ์ด gauge/donut/pills — reuse PFDonut · WinGauge · KpiPill (จาก Trades KPI) ทุกเลขจาก /analytics/summary (สะท้อน account/range) · client วาด/format เท่านั้น --------------------------------------------------------------------------- */ /* InfoIcon — ⓘ controlled tooltip · hover (desktop) + tap toggle popover (มือถือ) · แตะที่อื่น/Escape ปิด · hit area ≥44px (::after ใน CSS) · reuse mechanism เดียวกับ chart tooltip (P1-2) */ function InfoIcon({ tip }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; const onEsc = (e) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("pointerdown", onDoc); document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("pointerdown", onDoc); document.removeEventListener("keydown", onEsc); }; }, [open]); // hover (desktop) + tap (มือถือ): บน touch จะเกิด mouseenter→click ต่อกัน → ถ้า toggle จะหักล้างกันปิด · // จึงให้ click "เปิด" เสมอ (ไม่ toggle) แล้วปิดด้วย tap นอก/mouseleave/Escape → เปิดชัวร์ทั้งสองทาง return setOpen(true)} onMouseLeave={() => setOpen(false)}> {open && {tip}} ; } function DashboardKPICards({ t, s, sample }) { const gw = s.gross_win_cents || 0, gl = Math.abs(s.gross_loss_cents || 0); const pfText = s.pf_flag === "no_losses" ? "∞" : s.pf_flag === "no_trades" ? "—" : fmtNum(s.profit_factor); const aw = s.avg_win_cents || 0, al = Math.abs(s.avg_loss_cents || 0); const payoff = (s.avg_win_cents != null && al) ? aw / al : null; const barTot = aw + al || 1; const netColor = s.net_pnl_cents > 0 ? "var(--profit)" : s.net_pnl_cents < 0 ? "var(--loss)" : "var(--ink)"; const expColor = s.expectancy_cents > 0 ? "var(--profit)" : s.expectancy_cents < 0 ? "var(--loss)" : "var(--ink)"; const rExp = s.expectancy_r == null ? null : (s.expectancy_r >= 0 ? "+" : "−") + Math.abs(s.expectancy_r).toFixed(2) + "R"; const lbl = { margin: 0 }; // P1-1: KPI คลิก drilldown ได้ (net → trades · winrate/pf/avg/expectancy → insights?dimension) · // sample cards ไม่คลิก (ยังไม่มีข้อมูลจริง) · ⓘ มี stopPropagation อยู่แล้วกันคลิกซ้อน const cardProps = (path, extra) => { const cls = "kpi-card" + (extra ? " " + extra : ""); if (sample) return { className: cls }; return { className: cls + " kpi-clickable", role: "button", tabIndex: 0, onClick: () => navigate(path), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate(path); } } }; }; return
{sample &&
{t("common.sample")}
}
{/* 1 · Net P&L + count badge */}
{t("analytics.net_pnl")} {s.trades_closed}
{fmtMoney(s.net_pnl_cents, s.currency)}
{/* 2 · Trade win % + half-gauge + pills */}
{t("analytics.win_rate")}
{fmtPct(s.win_rate)}
{/* 3 · Profit factor + donut */}
{t("analytics.profit_factor")}
{pfText}
{/* 4 · Avg win/loss + proportion bar + $ */}
{t("analytics.avg_wl")}
{payoff == null ? "—" : fmtNum(payoff)}
{s.avg_win_cents == null ? "—" : fmtMoney(s.avg_win_cents, s.currency)} {s.avg_loss_cents == null ? "—" : fmtMoney(s.avg_loss_cents, s.currency)}
{/* 5 · Expectancy — desktop: การ์ดปกติใบท้ายแถว · มือถือ: การ์ดแบนเต็มกว้างล่างสุด (label ซ้าย · ค่า+R ขวา) */}
{t("analytics.expectancy")}
{s.expectancy_cents == null ? "—" : fmtMoney(s.expectancy_cents, s.currency)}
{rExp && {rExp} }{t("analytics.per_trade")}
; } /* Expectancy — การ์ดเสริมใต้ KPI strip (ดีไซน์ใหม่ไม่มีใน 5 การ์ด แต่เลขมีค่า) */ function ExpectancyCard({ t, s, sample }) { const rExp = s.expectancy_r == null ? null : (s.expectancy_r >= 0 ? "+" : "−") + Math.abs(s.expectancy_r).toFixed(2) + "R"; return
navigate("/app/insights?dimension=expectancy")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate("/app/insights?dimension=expectancy"); } }} style={{ position: "relative", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "var(--sp-3)", flexWrap: "wrap" }}> {sample && }
{t("analytics.expectancy")}
{t("analytics.per_trade")} {rExp && {rExp}}
; } /* FeesPanel — commission/swap/fee + รวม (จาก summary.fees) */ function FeesPanel({ t, fees, currency, sample }) { if (!fees) return null; const rows = [ [t("analytics.fees_commission"), fees.commission_cents], [t("analytics.fees_swap"), fees.swap_cents], [t("analytics.fees_fee"), fees.fee_cents], ]; return
{sample && }
{t("analytics.fees_title")}
{rows.map(([lbl, v], i) =>
{lbl}
)}
{t("analytics.fees_total")}
; } /* EquityCurve — ลายเซ็นแบรนด์ (จุดเดียวที่ม่วงแตะพื้นที่ข้อมูล ในฐานะ identity ไม่ใช่ semantic) */ function EquityCurve({ t, granularity, onGran, currency, maxDd }) { const [pts, setPts] = useState(null); const [loading, setLoading] = useState(true); const [hoverIdx, setHoverIdx] = useState(null); // P0-1: index จุดที่ cursor/tap ชี้อยู่ const wrapRef = useRef(null); const q = getQuery(); const acct = q.get("account_id") || ""; const range = q.get("range") || "all"; const cy = currency || "USD"; useEffect(() => { setLoading(true); setHoverIdx(null); const sp = new URLSearchParams(analyticsQS(acct, range).replace(/^\?/, "")); sp.set("granularity", granularity || "trade"); window.MTOS_API.get("/analytics/equity-curve?" + sp.toString()) .then((r) => { setPts(r.data.points || []); setLoading(false); }) .catch(() => { setPts([]); setLoading(false); }); }, [granularity, acct, range]); // P0-1: map cursor/tap x → index จุดใกล้สุด (frac ของ container เพราะ preserveAspectRatio=none) const onMove = (clientX) => { const el = wrapRef.current; if (!el || !pts || pts.length < 2) return; const rect = el.getBoundingClientRect(); if (!rect.width) return; let frac = (clientX - rect.left) / rect.width; frac = Math.max(0, Math.min(1, frac)); setHoverIdx(Math.round(frac * (pts.length - 1))); }; // P1-3: toggle ต่อเทรด/รายวัน แสดงทุกขนาด (มือถือด้วย) · ชิปเล็ก tap ≥44px บน touch (จัดสไตล์ใน .eq-gran) const toggle =
{[["trade", t("analytics.gran_trade")], ["day", t("analytics.gran_day")]].map(([g, lbl]) => )}
; // P1-4: Max Drawdown เป็น stat ย่อยใต้หัวข้อ "กำไรสะสม" (มาจาก equity curve → ที่อยู่ที่เหมาะสุด) · "—" เมื่อ null (<2 เทรด) const ddLine =
{t("analytics.max_drawdown")} {maxDd == null ? "—" : fmtMoney(maxDd, cy)}
; const header =
{t("analytics.equity_curve_label")}
{ddLine}
{toggle}
; let body; if (loading) body =
; else if (!pts || pts.length < 2) body =
; else { const w = 600, h = 200, pad = 8; const ys = pts.map((p) => p.cum_net_pnl_cents); const min = Math.min.apply(null, ys.concat([0])), max = Math.max.apply(null, ys.concat([0])); const span = max - min || 1; const x = (i) => pad + i * (w - pad * 2) / (pts.length - 1); const y = (v) => h - pad - (v - min) * (h - pad * 2) / span; const line = pts.map((p, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p.cum_net_pnl_cents).toFixed(1)).join(" "); const area = line + " L" + x(pts.length - 1).toFixed(1) + " " + (h - pad) + " L" + x(0).toFixed(1) + " " + (h - pad) + " Z"; const y0 = y(0); // x-axis: 4 label เดือนย่อ จากจุดจริง const months = t("calendar.months_short"); const labelAt = (p) => { const s = p.t || p.trade_date || ""; const d = parseYMD((s.split("T")[0]) || ""); return d ? d.getDate() + " " + months[d.getMonth()] : ""; }; /* #606: วัน+เดือน กัน label ซ้ำ */ const nLbl = 4, xlabels = []; for (let k = 0; k < nLbl; k++) { const idx = Math.round(k * (pts.length - 1) / (nLbl - 1)); xlabels.push([x(idx), labelAt(pts[idx])]); } // P0-1: label วันที่เต็มสำหรับ tooltip (trade → วัน+เดือน+เวลา · day → วัน+เดือน) const fmtPtDate = (p) => { const s = p.t || p.trade_date || ""; if (!s) return ""; if (s.indexOf("T") >= 0) return fmtTime(s); const d = parseYMD(s); return d ? d.getDate() + " " + months[d.getMonth()] : s; }; const hi = (hoverIdx != null && hoverIdx >= 0 && hoverIdx < pts.length) ? hoverIdx : null; const mkLeft = hi != null ? (x(hi) / w * 100) : 0; const mkTop = hi != null ? y(pts[hi].cum_net_pnl_cents) : 0; const hv = hi != null ? pts[hi].cum_net_pnl_cents : 0; const tipAnchor = mkLeft > 72 ? "right" : mkLeft < 28 ? "left" : "center"; body =
onMove(e.clientX)} onMouseLeave={() => setHoverIdx(null)} onTouchStart={(e) => { if (e.touches[0]) onMove(e.touches[0].clientX); }} onTouchMove={(e) => { if (e.touches[0]) onMove(e.touches[0].clientX); }} onTouchEnd={() => setHoverIdx(null)}> {/* crosshair + marker + tooltip (HTML overlay · left=% ของ container · top=px 1:1 กับ viewBox h) */} {hi != null &&
} {hi != null &&
} {hi != null &&
{fmtPtDate(pts[hi])}
0 ? "var(--profit)" : hv < 0 ? "var(--loss)" : "var(--ink)" }}>{fmtMoney(hv, cy)}
}
{xlabels.map((l, i) => {l[1]})}
; } return
{header}{body}
; } /* =========================================================================== StoryCard (Wave story) — การ์ดสรุปผลงานสัดส่วน 9:16 สำหรับแคปลง IG/TikTok story ดีไซน์ dark ม่วง (สีคงที่ · ไม่อิง theme) ให้ตัวเลขเด้ง · แคปหน้าจอแล้วลงสตอรี่ได้เลย DSI-safe: personal performance snapshot เท่านั้น · ไม่มี copy ชวนลงทุน/ขายซิกนัล Toggle "ซ่อนจำนวนเงิน" default = ซ่อน → โชว์ %+R+winrate+PF+avgW/L (ไม่มี $ · ไม่มีแกนเงิน) =========================================================================== */ /* palette คงที่ของการ์ด (independent จาก light/dark theme ของแอป) */ const STORY_CLR = { ink: "#ffffff", mute: "#c3b4ec", dim: "#a294cf", faint: "rgba(255,255,255,.55)", profit: "#54e6a6", loss: "#ff7a70", stroke: "#c77dff", strokeMid: "#b02fd0", strokeEnd: "#f0409a", panel: "rgba(255,255,255,.06)", panelLine: "rgba(255,255,255,.12)", }; /* กราฟกำไรสะสมย่อสำหรับ StoryCard (fetch เอง · reuse ลอจิก EquityCurve) */ function StoryEquityCurve({ t, showMoney, currency, deltaLabel }) { const [pts, setPts] = useState(null); const q = getQuery(); const acct = q.get("account_id") || ""; const range = q.get("range") || "all"; useEffect(() => { const sp = new URLSearchParams(analyticsQS(acct, range).replace(/^\?/, "")); sp.set("granularity", "day"); window.MTOS_API.get("/analytics/equity-curve?" + sp.toString()) .then((r) => setPts(r.data.points || [])).catch(() => setPts([])); }, [acct, range]); const H = 168; if (!pts) return
; if (pts.length < 2) return
{t("analytics.not_enough_curve")}
; const w = 600, h = 200, pad = 8; const ys = pts.map((p) => p.cum_net_pnl_cents); const min = Math.min.apply(null, ys.concat([0])), max = Math.max.apply(null, ys.concat([0])); const span = max - min || 1; const x = (i) => pad + i * (w - pad * 2) / (pts.length - 1); const y = (v) => h - pad - (v - min) * (h - pad * 2) / span; const line = pts.map((p, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p.cum_net_pnl_cents).toFixed(1)).join(" "); const area = line + " L" + x(pts.length - 1).toFixed(1) + " " + (h - pad) + " L" + x(0).toFixed(1) + " " + (h - pad) + " Z"; const y0 = y(0); return
{deltaLabel}
; } /* KPI cell เล็กบนการ์ด story */ function StoryKpi({ label, value, accent }) { return
{label}
{value}
; } function StoryCard({ t, s, range, isDemo, onClose }) { const [hideMoney, setHideMoney] = useState(true); // default = ซ่อนเงิน (DSI-safe) useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const rangeMap = { "7d": "range_7d", "30d": "range_30d", "90d": "range_90d", "all": "range_all" }; const rangeText = t("filters." + (rangeMap[range] || "range_all")); const showMoney = !hideMoney; const pfText = s.pf_flag === "no_losses" ? "∞" : s.pf_flag === "no_trades" ? "—" : fmtNum(s.profit_factor); const payoff = s.payoff_ratio == null ? "—" : fmtNum(s.payoff_ratio); const rExp = s.expectancy_r == null ? "—" : (s.expectancy_r >= 0 ? "+" : "−") + Math.abs(s.expectancy_r).toFixed(2) + "R"; const totalR = s.total_r == null ? "—" : (s.total_r >= 0 ? "+" : "−") + Math.abs(s.total_r).toFixed(1) + "R"; const growth = s.growth_pct == null ? "—" : (s.growth_pct >= 0 ? "+" : "−") + Math.abs(s.growth_pct * 100).toFixed(1) + "%"; const growthColor = (s.growth_pct || 0) >= 0 ? STORY_CLR.profit : STORY_CLR.loss; const netMoney = fmtMoney(s.net_pnl_cents, s.currency); const netColor = (s.net_pnl_cents || 0) >= 0 ? STORY_CLR.profit : STORY_CLR.loss; const deltaLabel = showMoney ? fmtMoneyShort(s.net_pnl_cents, s.currency) : growth; const cardBg = "linear-gradient(157deg, #1a1140 0%, #241452 46%, #3d1668 100%)"; const brandM =
M
MyTraderOS
; return
{/* close X มุมขวาบน overlay (นอกการ์ด · ไม่ติดในภาพแคป) */} {/* การ์ด 9:16 */}
{/* header: brand + demo badge */}
{brandM} {isDemo && {t("story.demo_badge")}}
{/* subtitle + period */}
{t("story.subtitle")}
{rangeText} · {t("story.trades_n", { n: s.trades_closed })}
{/* headline: growth% (ซ่อนเงิน) หรือ net$ (โชว์เงิน) */}
{showMoney ? t("analytics.net_pnl") : t("story.growth")}
{showMoney ? netMoney : growth}
{/* equity curve — ตัวชูโรง */}
{/* KPI grid */}
{/* spacer + watermark */}
{t("story.watermark")}
{/* controls (นอกการ์ด) */}
{t("story.capture_hint")}
; } /* DashboardCalendar (Wave dashcal) — ปฏิทินเต็มแบบ TradeZella บน Dashboard header เลื่อนเดือน ‹ › + ชิป "เดือนนี้" + สถิติเดือน · ช่องวันใหญ่ P&L + N เทรด + %ชนะ · คอลัมน์สรุปสัปดาห์ (desktop) / แถบสรุปสัปดาห์ (mobile) · แตะวัน -> /app/calendar?day= เลขทุกตัวจาก /analytics/calendar (mock คำนวณ win_rate/status) · client format เท่านั้น · reusable: month เก็บใน local state (ไม่แตะ URL) · weekday Mon-first ให้ตรงทั้งแอป (/app/calendar) */ function DashboardCalendar({ t, currency }) { const curMonth = ymNow(); const [month, setMonth] = useState(curMonth); const [res, setRes] = useState(null); const [err, setErr] = useState(false); const isDesktop = useIsDesktop(); const acct = getQuery().get("account_id") || ""; // P0-4: ปฏิทินสะท้อนบัญชีที่เลือก (สลับ chip → re-fetch) useEffect(() => { let alive = true; setRes(null); setErr(false); window.MTOS_API.get("/analytics/calendar?month=" + month + (acct ? "&account_id=" + encodeURIComponent(acct) : "")) .then((r) => { if (alive) setRes(r); }) .catch(() => { if (alive) setErr(true); }); return () => { alive = false; }; }, [month, acct]); const go = (delta) => { const p = month.split("-").map(Number); let m = p[1] + delta, y = p[0]; if (m < 1) { m = 12; y--; } if (m > 12) { m = 1; y++; } setMonth(y + "-" + pad2(m)); }; const ym = parseYM(month) || parseYM(curMonth); const cy = currency || (res && res.meta && res.meta.currency) || "USD"; const monthName = t("calendar.months")[ym.m - 1] + " " + ym.y; const isCur = month === curMonth; const ms = res && res.data && res.data.month_summary; const monthNet = ms ? ms.net_pnl_cents : null; const statColor = monthNet == null ? "var(--ink-mute)" : monthNet > 0 ? "var(--profit)" : monthNet < 0 ? "var(--loss)" : "var(--ink)"; const navBtn = { width: 34, height: 34, borderRadius: "var(--radius-sm)", border: "1px solid var(--hairline)", background: "transparent", color: "var(--ink)", cursor: "pointer", fontSize: 17, lineHeight: 1, flex: "0 0 auto" }; const header =
{monthName}
{!isCur && }
{t("calendar.month_stat")}: {monthNet == null ? "—" : fmtMoney(monthNet, cy)}
; if (err) return
{header}
{t("states.error_title")}
; if (!res) return
{header}
; const dmap = {}; (res.data.days || []).forEach((d) => { dmap[d.trade_date] = d; }); const wmap = {}; (res.data.weekly || []).forEach((w) => { wmap[w.week_start] = w; }); const first = new Date(ym.y, ym.m - 1, 1); const lead = (first.getDay() + 6) % 7; // Mon-first offset const dim = new Date(ym.y, ym.m, 0).getDate(); const cells = []; for (let i = 0; i < lead; i++) cells.push(null); for (let d = 1; d <= dim; d++) cells.push(d); while (cells.length % 7) cells.push(null); const weeks = []; for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); const mondayOf = (wi) => ymd(new Date(ym.y, ym.m - 1, 1 + wi * 7 - lead)); const weekdays = t("calendar.weekdays"); const monShort = t("calendar.months_short")[ym.m - 1]; const today = ymd(new Date()); const weekColW = 128; const tintOf = (st) => st === "win" ? "var(--profit)" : st === "loss" ? "var(--loss)" : "var(--brand-bright)"; const inkOf = (st) => st === "win" ? "var(--profit)" : st === "loss" ? "var(--loss)" : "var(--ink-mute)"; const dayH = isDesktop ? 66 : 48; const Cell = ({ num }) => { if (!num) return
; const date = month + "-" + pad2(num); const d = dmap[date]; const isToday = date === today; const winPct = d && d.win_rate != null ? Math.round(d.win_rate * 100) : null; const line = d ? ((isDesktop && winPct != null) ? t("calendar.cell_line", { n: d.trades_closed_count, wr: winPct }) : t("calendar.cell_line_short", { n: d.trades_closed_count })) : ""; const tint = d ? tintOf(d.status) : null; const bg = d ? "color-mix(in srgb, " + tint + " " + (d.status === "breakeven" ? "10%" : "15%") + ", transparent)" : "transparent"; const border = isToday ? "1px solid var(--brand-edge)" : d ? "1px solid color-mix(in srgb, " + tint + " 22%, transparent)" : "1px solid var(--hairline)"; const aria = d ? num + " " + monShort + " " + fmtMoneyShort(d.net_pnl_cents, cy) + ", " + line : String(num); return ; }; const WeekCard = ({ wi }) => { const w = wmap[mondayOf(wi)]; const empty = !w; const net = w ? w.net_pnl_cents : null; const c = net == null ? "var(--ink-subtle)" : net > 0 ? "var(--profit)" : net < 0 ? "var(--loss)" : "var(--ink)"; return
{t("calendar.week_label")} {wi + 1} {net == null ? "—" : fmtMoneyShort(net, cy)} {w && {t("calendar.days_short", { n: w.traded_days_count })}}
; }; return
{header} {/* weekday header (จัดแนวกับ grid + เว้นคอลัมน์ week ฝั่ง desktop) */}
{weekdays.map((w, i) =>
{w}
)}
{/* week rows: 7 วัน + การ์ดสรุปสัปดาห์ (desktop) */} {weeks.map((wk, wi) =>
{wk.map((num, ci) => )}
)} {/* mobile: แถบสรุปสัปดาห์ท้าย grid (ซ่อนคอลัมน์ · กัน hscroll) */}
{t("calendar.weekly_subtotal")}
{weeks.map((wk, wi) => { const w = wmap[mondayOf(wi)]; if (!w) return null; const c = w.net_pnl_cents > 0 ? "var(--profit)" : w.net_pnl_cents < 0 ? "var(--loss)" : "var(--ink)"; return
{t("calendar.week_label")} {wi + 1} · {t("calendar.days_short", { n: w.traded_days_count })} {fmtMoneyShort(w.net_pnl_cents, cy)}
; })}
{/* footer: สรุปเดือน + ลิงก์ปฏิทินเต็ม */}
{t("analytics.win_rate")}: {fmtPct(ms && ms.win_rate)} {t("calendar.trade_count", { n: ms ? ms.trades_closed_count : 0 })}
; } /* DashboardSummaryBar (Wave onecal) — แถบสรุปบนสุด: สุทธิ + อัตราชนะ + จำนวนเทรด อิงตัวกรอง 30วัน/บัญชี (จาก /analytics/summary ที่ filter-aware) — คนละอันกับ "สถิติเดือน" ในการ์ดปฏิทิน (อิงเดือน) · label สุทธิ ต่อท้ายด้วยช่วง (เช่น "สุทธิ 30 วัน") กันสับสน */ function DashboardSummaryBar({ t, s, range }) { const rangeMap = { "7d": "range_7d", "30d": "range_30d", "90d": "range_90d", "all": "range_all" }; const rangeText = t("filters." + (rangeMap[range] || "range_all")); const net = s.net_pnl_cents; const netColor = net > 0 ? "var(--profit)" : net < 0 ? "var(--loss)" : "var(--ink)"; const cell = (label, value, color) =>
{label}
{value}
; return
{cell(t("analytics.summary_net") + " " + rangeText, fmtMoney(net, s.currency), netColor)} {cell(t("analytics.summary_winrate"), fmtPct(s.win_rate))} {cell(t("analytics.summary_trades"), String(s.trades_closed))}
; } /* DailyPnlCard (Wave 2cards) — บาร์ชาร์ต net P&L รายวัน · SVG เขียนเอง (0 external) · แท่งกำไรเขียวขึ้นเหนือเส้น $0 · ขาดทุนแดงลงใต้เส้น · แกน y label เงิน · แกน x วันที่ย่อ · grid dashed · อิงตัวกรอง 30วัน/บัญชี (เหมือน KPI) · states: loading · empty · error */ function DailyPnlCard({ t, currency }) { const [days, setDays] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(false); const [hoverBar, setHoverBar] = useState(null); // P0-2: index แท่งที่ hover/tap = activeDay (source of truth) const colRef = useRef(null); const touchRef = useRef(false); // scrub-fix: true = input ล่าสุดเป็น touch (กัน container-tap เปิดวันเอง บนมือถือ) const q = getQuery(); const acct = q.get("account_id") || ""; const range = q.get("range") || "all"; useEffect(() => { let alive = true; setLoading(true); setErr(false); setHoverBar(null); window.MTOS_API.get("/analytics/daily-pnl" + analyticsQS(acct, range)) .then((r) => { if (alive) { setDays(r.data.days || []); setLoading(false); } }) .catch(() => { if (alive) { setErr(true); setLoading(false); } }); return () => { alive = false; }; }, [acct, range]); const cy = currency || "USD"; const header =
{t("analytics.daily_pnl_label")}
; let body; if (loading) body =
; else if (err) body =
{t("states.error_title")}
; else if (!days || days.length === 0) body =
; else { const w = 600, h = 210, padL = 8, padR = 8, padT = 12, padB = 8; const vals = days.map((d) => d.net_pnl_cents); const maxV = Math.max.apply(null, vals.concat([0])); const minV = Math.min.apply(null, vals.concat([0])); const span = (maxV - minV) || 1; const plotH = h - padT - padB, plotW = w - padL - padR; const yOf = (v) => padT + (maxV - v) / span * plotH; // viewBox y (1:1 กับ px แนวตั้ง) const y0 = yOf(0); const n = days.length; const slot = plotW / n; const bw = Math.max(3, Math.min(26, slot * 0.62)); const gridVals = [maxV, (maxV + minV) / 2, minV].filter((v, i, a) => a.indexOf(v) === i); const months = t("calendar.months_short"); const labelOf = (d) => { const dt = parseYMD(d.trade_date); return dt ? dt.getDate() + " " + months[dt.getMonth()] : ""; }; const step = Math.max(1, Math.ceil(n / 6)); const gutter = 52; // ช่องซ้ายสำหรับ label เงิน // P0-2: map pointer x → index แท่ง · center แท่ง · tooltip anchor กันล้นขอบ const barCx = (i) => padL + slot * i + slot / 2; const barIdxAt = (clientX) => { const el = colRef.current; if (!el) return null; const rect = el.getBoundingClientRect(); if (!rect.width) return null; let frac = (clientX - rect.left) / rect.width; frac = Math.max(0, Math.min(1, frac)); let idx = Math.floor((frac * w - padL) / slot); return Math.max(0, Math.min(n - 1, idx)); }; const hb = (hoverBar != null && hoverBar >= 0 && hoverBar < n) ? hoverBar : null; // scrub-fix: เปิดวันยึด activeDay (hb = วันที่ tooltip โชว์) เป็น single source of truth เสมอ · ห้าม re-compute จากตำแหน่ง click/tap const openActive = () => { if (hb != null && days[hb]) patchQuery({ day: days[hb].trade_date }); }; const dv = hb != null ? days[hb].net_pnl_cents : 0; const hbLeft = hb != null ? (barCx(hb) / w * 100) : 0; const tAnchor = hbLeft > 72 ? "translateX(-100%)" : hbLeft < 28 ? "translateX(0)" : "translateX(-50%)"; body =
{/* y-axis labels (เงิน) — align กับเส้น grid ด้วย % (svg ยืดเต็มความสูง · spacer ล่างเท่า x-labels) */}
{gridVals.map((gv, i) => {fmtMoneyShort(gv, cy)})}
{ touchRef.current = false; setHoverBar(barIdxAt(e.clientX)); }} onMouseLeave={() => setHoverBar(null)} onTouchStart={(e) => { touchRef.current = true; if (e.touches[0]) setHoverBar(barIdxAt(e.touches[0].clientX)); }} onTouchMove={(e) => { touchRef.current = true; if (e.touches[0]) setHoverBar(barIdxAt(e.touches[0].clientX)); }} onClick={() => { if (touchRef.current) return; openActive(); }}> {gridVals.map((gv, i) => )} {days.map((d, i) => { const cx = padL + slot * i + slot / 2; const v = d.net_pnl_cents; const yv = yOf(v); const top = Math.min(yv, y0), hgt = Math.max(Math.abs(yv - y0), 1); const col = v > 0 ? "var(--profit)" : v < 0 ? "var(--loss)" : "var(--breakeven)"; return 0 ? "pos" : v < 0 ? "neg" : "zero"} />; })} {/* x-axis labels (วันที่ย่อ) */}
{days.map((d, i) => (i % step === 0) ? {labelOf(d)} : null)}
{/* custom tooltip (P0-2 · วัน + net สี + จำนวนเทรด) — HTML overlay ลอยบนสุด กัน scale ปัญหา */} {hb != null && days[hb] &&
{ e.stopPropagation(); openActive(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openActive(); } }} style={{ position: "absolute", left: hbLeft + "%", top: 0, transform: tAnchor, background: "var(--surface-1)", border: "1px solid var(--hairline)", borderRadius: "var(--radius-sm)", boxShadow: "var(--shadow-2)", padding: "5px 9px", cursor: "pointer", pointerEvents: "auto", whiteSpace: "nowrap", zIndex: 6 }}>
{labelOf(days[hb])}
0 ? "var(--profit)" : dv < 0 ? "var(--loss)" : "var(--ink)" }}>{fmtMoney(dv, cy)}
{days[hb].trades_count != null &&
{t("calendar.trade_count", { n: days[hb].trades_count })}
}
{t("analytics.tap_detail")} ›
}
; } return
{header}{body}
; } /* RecentTradesCard (Wave 2cards) — 2 แท็บ: เทรดล่าสุด (closed · -closed_at · 6) / โพซิชันเปิด (status open) · 3 คอลัมน์: วันที่ปิด · สินค้า (chip ม่วง + badge EA/มือ) · กำไร/ขาดทุน (สี · tabular-nums) · แตะแถว → /app/trades/{id} · อิงบัญชีที่เลือก (ไม่อิง range — "ล่าสุด" = ล่าสุดจริง) · empty แยกแท็บ */ function RecentTradesCard({ t }) { const [tab, setTab] = useState("recent"); // recent | open const [recent, setRecent] = useState(null); const [open, setOpen] = useState(null); const [err, setErr] = useState(false); const q = getQuery(); const acct = q.get("account_id") || ""; useEffect(() => { let alive = true; setErr(false); setRecent(null); setOpen(null); const acctQS = acct ? "&account_id=" + encodeURIComponent(acct) : ""; window.MTOS_API.get("/trades?sort=-closed_at&status=closed&limit=6" + acctQS) .then((r) => { if (alive) setRecent(r.data || []); }).catch(() => { if (alive) setErr(true); }); window.MTOS_API.get("/trades?status=open&sort=-opened_at&limit=20" + acctQS) .then((r) => { if (alive) setOpen(r.data || []); }).catch(() => { if (alive) setErr(true); }); return () => { alive = false; }; }, [acct]); const tabBtn = (key, label) => { const on = tab === key; return ; }; const SymChip = ({ tr }) => {tr.symbol} ; const Row = ({ tr }) => { const isOpen = tr.status === "open"; const dstr = tr.closed_at ? fmtTime(tr.closed_at) : "—"; return ; }; const rows = tab === "recent" ? recent : open; const loading = rows === null; let body; if (loading && !err) body =
; else if (err && !rows) body =
{t("states.error_title")}
; else if (!rows || rows.length === 0) body =
{tab === "open" ? t("trades.open_empty") : t("trades.recent_empty")}
; else body =
{t("trades.col_time")}{t("trades.col_symbol")}{t("trades.col_pnl")}
{rows.map((tr) => )}
; return
{tabBtn("recent", t("trades.recent_title"))} {tabBtn("open", t("trades.open_title"))}
{body}
; } /* Dashboard เต็ม (Wave 1) — 5 tile · fees · equity · heatmap + ทุก state */ function DashboardPage({ t, toast }) { const [devState, setDevState] = useState("ready"); // dev state switcher const [gran, setGran] = useState("trade"); const [storyOpen, setStoryOpen] = useState(false); // StoryCard 9:16 overlay const [phase, setPhase] = useState("loading"); // loading|ready|empty|error|locked const [summary, setSummary] = useState(null); const [meta, setMeta] = useState(null); const [errCode, setErrCode] = useState(null); const [lockInfo, setLockInfo] = useState(null); const [sample, setSample] = useState(null); const [accts, setAccts] = useState(null); const q = getQuery(); const acct = q.get("account_id") || ""; const range = q.get("range") || "all"; const noAccounts = accts && accts.length === 0; // deep-link ปฏิทิน inline: ?day=YYYY-MM-DD → เปิด DayDetailSheet (ยกมาจากหน้า /calendar เดิม) const dayParam = q.get("day"); const dayDate = parseYMD(dayParam); const todayMid = new Date(); todayMid.setHours(0, 0, 0, 0); const dayValid = !!(dayDate && dayDate <= todayMid); useEffect(() => { if (dayParam && !dayValid) patchQuery({ day: null }, true); }, [dayParam, dayValid]); const sheetOpen = !!(dayParam && dayValid); useEffect(() => { window.MTOS_API.get("/accounts").then((r) => setAccts(r.data)).catch(() => setAccts([])); }, []); const load = useCallback((sim) => { setPhase("loading"); if (sim === "loading") return; if (sim === "empty") { setTimeout(() => setPhase("empty"), 120); return; } const opts = sim === "error" ? { simulate: "error" } : sim === "locked" ? { simulate: "locked" } : {}; window.MTOS_API.get("/analytics/summary" + analyticsQS(acct, range), opts) .then((r) => { setSummary(r.data); setMeta(r.meta); setPhase(r.data.trades_closed === 0 ? "empty" : "ready"); }) .catch((e) => { if (e.status === 409) { const det = (e.envelope && e.envelope.error && e.envelope.error.details) || { minutes: 6, progress: 0.35 }; setLockInfo(det); window.MTOS_API.get("/analytics/sample").then((sr) => { setSample(sr.data); setPhase("locked"); }).catch(() => { setErrCode("sample_fail"); setPhase("error"); }); } else { setErrCode((e.envelope && e.envelope.error && e.envelope.error.code) || "error"); setPhase("error"); } }); }, [acct, range]); useEffect(() => { load(devState === "ready" ? null : devState); }, [devState, load]); // ปุ่มแชร์สตอรี่ — render ในแถวหัวข้อ "ภาพรวม" (ชิดขวา) เฉพาะตอน phase ready ที่มี summary const shareBtn = ; return
{phase === "loading" &&
} {phase === "error" && load(null)} />} {phase === "empty" && noAccounts ? navigate("/app/connect") : patchQuery({ quickadd: "1" })} />} {phase === "locked" && sample &&
{t("analytics.first_sync_note")}
} {phase === "ready" && summary &&
} {/* ปฏิทิน inline: แตะวันในการ์ดปฏิทิน → เปิด DayDetailSheet (reuse component หน้า /calendar เดิม) */} {sheetOpen && patchQuery({ day: null })} toast={toast} currency={summary && summary.currency} />} {/* StoryCard 9:16 overlay — แชร์สตอรี่ (เปิดจากปุ่มแชร์ · เฉพาะ phase ready ที่มี summary) */} {storyOpen && summary && setStoryOpen(false)} />}
; } /* =========================================================================== Calendar (Wave 1) — MonthHeader · MonthGrid · WeeklySubtotal · DayDetailSheet =========================================================================== */ function MonthHeader({ t, ym, data }) { const ms = data.month_summary || {}; const name = t("calendar.months")[ym.m - 1] + " " + ym.y; const go = (delta) => { let m = ym.m + delta, y = ym.y; if (m < 1) { m = 12; y--; } if (m > 12) { m = 1; y++; } patchQuery({ month: y + "-" + pad2(m), day: null }); }; const btn = { width: 40, height: 40, borderRadius: "var(--radius-sm)", border: "1px solid var(--hairline)", background: "transparent", color: "var(--ink)", cursor: "pointer", fontSize: 18 }; return
{name}
{t("calendar.net_month")}
{t("analytics.win_rate")}
{fmtPct(ms.win_rate)}
{t("calendar.trade_count", { n: "" }).replace("{n}", "").trim() || "เทรด"}
{ms.trades_closed_count}
; } function MonthGrid({ t, ym, data, sample }) { const dmap = {}; (data.days || []).forEach((d) => { dmap[d.trade_date] = d; }); const wmap = {}; (data.weekly || []).forEach((w) => { wmap[w.week_start] = w; }); const first = new Date(ym.y, ym.m - 1, 1); const lead = (first.getDay() + 6) % 7; const dim = new Date(ym.y, ym.m, 0).getDate(); const cells = []; for (let i = 0; i < lead; i++) cells.push(null); for (let d = 1; d <= dim; d++) cells.push(d); while (cells.length % 7) cells.push(null); const weeks = []; for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); const mondayOf = (wi) => { const dd = new Date(ym.y, ym.m - 1, 1 + wi * 7 - lead); return ymd(dd); }; const weekdays = t("calendar.weekdays"); const monShort = t("calendar.months_short")[ym.m - 1]; const today = ymd(new Date()); const openDay = (date) => patchQuery({ day: date, month: data.month }); const cellColor = (d) => { const n = d.net_pnl_cents; return n > 0 ? "var(--profit)" : n < 0 ? "var(--loss)" : "var(--surface-3)"; }; const dotStr = (n) => n <= 3 ? "•".repeat(n) : "×" + n; const Cell = ({ num, week }) => { if (!num) return
; const date = data.month + "-" + pad2(num); const d = dmap[date]; const isToday = date === today; return ; }; return
{/* weekday header */}
{weekdays.map((w, i) =>
{w}
)}
{weeks.map((wk, wi) =>
{wk.map((num, ci) => )}
)} {/* WeeklySubtotal — desktop = ท้ายเป็นบล็อค · mobile = list ท้าย scroll */}
{t("calendar.weekly_subtotal")}
{weeks.map((wk, wi) => { const w = wmap[mondayOf(wi)]; if (!w) return null; return
{t("calendar.week_label")} {wi + 1} · {t("calendar.traded_days", { n: w.traded_days_count })}
; })}
; } function SessionStrip({ t, sessions }) { if (!sessions || !sessions.length) return null; const order = ["asia", "london", "newyork", "off"]; const sorted = sessions.slice().sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)); return
{t("calendar.day_sessions")}
{sorted.map((s) =>
{t("session." + s.key)} · {t("calendar.trade_count", { n: s.trades })}
)}
; } /* DayDetailSheet — mobile bottom-sheet 85% · desktop side panel ขวา */ function DayDetailSheet({ t, date, sample, onClose, toast, currency }) { const [d, setD] = useState(null); const [note, setNote] = useState(""); const [noteState, setNoteState] = useState(""); // "" | saving | saved const timer = useRef(null); const cy = currency || "USD"; const acct = getQuery().get("account_id") || ""; // P0-4: day-sheet สะท้อนบัญชีเดียวกับ KPI/ปฏิทิน useEffect(() => { setD(null); setNoteState(""); window.MTOS_API.get("/calendar/days/" + date + (acct ? "?account_id=" + encodeURIComponent(acct) : "")).then((r) => { setD(r.data); setNote((r.data.daily_note && r.data.daily_note.body) || ""); }).catch(() => setD({ error: true })); }, [date, acct]); useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const onNote = (e) => { const v = e.target.value; setNote(v); setNoteState("saving"); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => { window.MTOS_API.put("/daily-notes/" + date, { body: v }).then(() => setNoteState("saved")).catch(() => setNoteState("")); }, 600); }; const summary = d && d.summary; // ‹ › เลื่อนวัน ±1 ตามปฏิทิน (patch ?day → re-fetch) · next ห้ามไปอนาคต (guard เหมือน E10) · prev อิสระ const curDate = parseYMD(date); const todayMidD = new Date(); todayMidD.setHours(0, 0, 0, 0); const canNext = !!(curDate && new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() + 1) <= todayMidD); const shiftDay = (delta) => { if (!curDate) return; const nd = new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() + delta); if (delta > 0 && nd > todayMidD) return; // กันวันอนาคต patchQuery({ day: ymd(nd) }); }; const content =
{date}
{sample && {t("common.sample")}}
{!d &&
} {d && d.error && { }} />} {d && !d.error &&
{/* สรุปวัน */}
{t("calendar.day_summary")}
{summary ?
{t("analytics.trades_line", { count: summary.trades_closed_count, wins: summary.wins_count, losses: summary.losses_count, be: summary.be_count })}
:
{t("calendar.no_trades_day")}
}
{/* รายการเทรด */} {d.trades && d.trades.length > 0 &&
{t("calendar.day_trades")}
{d.trades.map((tr) => )}
} {/* daily note + autosave */}
{t("calendar.day_note")} {noteState === "saving" ? t("calendar.note_saving") : noteState === "saved" ? t("calendar.note_saved") : ""}