/* ============================================================================ 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 } = 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 ---- */ 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(window.location.pathname + window.location.search); useEffect(() => { const on = () => setLoc(window.location.pathname + window.location.search); 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(window.location.search); } function patchQuery(patch, replace) { const sp = new URLSearchParams(window.location.search); 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(window.location.pathname + (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" }, ]; 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 = { 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) {} }, startDemo() { try { localStorage.setItem("mtos_session", "1"); localStorage.setItem("mtos_demo", "1"); localStorage.setItem("mtos_token", "mock.jwt.token"); } catch (e) {} }, isDemo() { try { return localStorage.getItem("mtos_demo") === "1"; } catch (e) { return false; } }, logout() { try { localStorage.removeItem("mtos_session"); localStorage.removeItem("mtos_token"); localStorage.removeItem("mtos_demo"); localStorage.removeItem("mtos_user"); } 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 */ function Dropdown({ label, dot, open, onToggle, children }) { 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 30d = ไม่มี param) */ function DateRangeChip({ t }) { const [open, setOpen] = useState(false); const sel = getQuery().get("range") || "30d"; 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[1]; const pick = (v) => { setOpen(false); patchQuery({ range: v === "30d" ? 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: "calendar", to: "/app/calendar", label: t("app.nav.calendar"), 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: t("app.nav.insights"), icon: "◎" }, ] }, ]; const TABS = (t) => [ { name: "dashboard", to: "/app", label: t("app.nav.dashboard"), icon: "▦" }, { name: "calendar", to: "/app/calendar", label: t("app.nav.calendar"), icon: "◱" }, { name: "__fab" }, { name: "trades", to: "/app/trades", label: t("app.nav.trades"), 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); }; return
{/* header (chrome มืด — mtos-chrome ตั้ง token subtree ให้ลูกทุกตัวเป็นโทนมืด) */}
{Session.isDemo() && {t("demo.badge_short")} · {t("demo.badge_suffix")} }
{/* 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} ; } /* =========================================================================== 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 }) { return

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

{t("pages." + page + ".subtitle")}
; } /* 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 เท่านั้น --------------------------------------------------------------------------- */ function InfoIcon({ tip }) { return i; } 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 lbl = { margin: 0 }; 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 · Day win % + half-gauge + pills (วันชนะ/เสมอ/แพ้) */}
{t("analytics.day_win_rate")}
{fmtPct(s.day_win_rate)}
{/* 5 · 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)}
; } /* 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 }) { const [pts, setPts] = useState(null); const [loading, setLoading] = useState(true); const q = getQuery(); const acct = q.get("account_id") || ""; const range = q.get("range") || "30d"; useEffect(() => { setLoading(true); 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]); const toggle =
{[["trade", t("analytics.gran_trade")], ["day", t("analytics.gran_day")]].map(([g, lbl]) => )}
; const header =
{t("analytics.equity_curve_label")}{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 ? months[d.getMonth()] : ""; }; 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])]); } body =
{xlabels.map((l, i) => {l[1]})}
; } return
{header}{body}
; } /* MiniHeatmap (C8) — เดือนปัจจุบัน · สีตาม sign · แตะวัน -> /app/calendar?day= */ function MiniHeatmap({ t }) { const [res, setRes] = useState(null); const [err, setErr] = useState(false); useEffect(() => { window.MTOS_API.get("/analytics/calendar?month=" + ymNow()).then(setRes).catch(() => setErr(true)); }, []); if (err) return null; if (!res) return
; const ym = parseYM(res.data.month) || parseYM(ymNow()); const dmap = {}; (res.data.days || []).forEach((d) => { dmap[d.trade_date] = d; }); 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); const weekdays = t("calendar.weekdays"); const monthName = t("calendar.months")[ym.m - 1] + " " + ym.y; // TradeZella-style: พื้นเขียว/แดงอ่อน + ตัวเลข P&L เด่นกลางช่อง · เลขวันเล็กมุมบน const cellBg = (d) => { if (!d) return "var(--zebra)"; const n = d.net_pnl_cents; const c = n > 0 ? "var(--profit)" : n < 0 ? "var(--loss)" : "var(--surface-3)"; return "color-mix(in srgb, " + c + " 15%, transparent)"; }; const cellInk = (d) => { const n = d.net_pnl_cents; return n > 0 ? "var(--profit)" : n < 0 ? "var(--loss)" : "var(--ink-mute)"; }; return
{weekdays.map((w, i) =>
{w}
)} {cells.map((num, i) => { if (!num) return
; const date = res.data.month + "-" + pad2(num); const d = dmap[date]; const aria = d ? num + " " + t("calendar.months_short")[ym.m - 1] + " " + fmtMoneyShort(d.net_pnl_cents, res.meta.currency) + ", " + d.trades_closed_count + " ไม้" : String(num); return ; })}
{t("analytics.month_total")}
; } /* 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 [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") || "30d"; const noAccounts = accts && accts.length === 0; 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]); 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 &&
}
; } /* =========================================================================== 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 }) { const [d, setD] = useState(null); const [note, setNote] = useState(""); const [noteState, setNoteState] = useState(""); // "" | saving | saved const timer = useRef(null); useEffect(() => { setD(null); setNoteState(""); window.MTOS_API.get("/calendar/days/" + date).then((r) => { setD(r.data); setNote((r.data.daily_note && r.data.daily_note.body) || ""); }).catch(() => setD({ error: true })); }, [date]); 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; 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") : ""}