/* ============================================================================
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)}>
{(accts || []).map((a) => )}
;
}
/* 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) => )}
;
}
/* 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 ;
}
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")}
{/* 3 · Profit factor + donut */}
{t("analytics.profit_factor")}
{/* 4 · Day win % + half-gauge + pills (วันชนะ/เสมอ/แพ้) */}
{t("analytics.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") : ""}
}
;
return ReactDOM.createPortal(
{/* mobile bottom-sheet (wrapper คุม display ผ่าน class · panel เป็น block ปกติ) */}
{/* desktop side panel ขวา */}
, document.body);
}
/* ===========================================================================
Wave 2.5: Calendar desktop — day cards (expandable) + sticky mini-calendar
เฉพาะ ≥1024px · มือถือคงเดิม (MonthGrid + DayDetailSheet)
=========================================================================== */
function fmtDayLabel(date, t) {
const d = parseYMD(date); if (!d) return date;
return d.getDate() + " " + t("calendar.months_short")[d.getMonth()] + " " + d.getFullYear();
}
/* mini equity ของวัน (intraday cumulative · line + area) */
function DayMiniChart({ t, points }) {
if (!points || points.length < 2) return {t("calendar.no_intraday")}
;
const w = 300, h = 120, pad = 8;
const ys = points.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) / (points.length - 1);
const y = (v) => h - pad - (v - min) * (h - pad * 2) / span;
const up = ys[ys.length - 1] >= 0;
const col = up ? "var(--profit)" : "var(--loss)";
const line = points.map((p, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p.cum_net_pnl_cents).toFixed(1)).join(" ");
const area = line + " L" + x(points.length - 1).toFixed(1) + " " + (h - pad) + " L" + x(0).toFixed(1) + " " + (h - pad) + " Z";
const y0 = y(0);
return ;
}
/* daily note editor (desktop day card) — autosave debounce → PUT /daily-notes */
function DayNoteEditor({ t, date, initial }) {
const [note, setNote] = useState(initial || "");
const [state, setState] = useState("");
const timer = useRef(null);
useEffect(() => { setNote(initial || ""); setState(""); }, [date]);
const onChange = (e) => {
const v = e.target.value; setNote(v); setState("saving");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => { window.MTOS_API.put("/daily-notes/" + date, { body: v }).then(() => setState("saved")).catch(() => setState("")); }, 600);
};
return
{t("calendar.day_note")}
{state === "saving" ? t("calendar.note_saving") : state === "saved" ? t("calendar.note_saved") : ""}
;
}
/* DayCard — collapsed: caret+วันที่+Net P&L · expand: mini chart + 7 stats + trades table + note */
function DayCard({ t, date, agg, currency, expanded, onToggle }) {
const [d, setD] = useState(null);
const [err, setErr] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!expanded) return;
if (d || err) return;
setErr(false);
window.MTOS_API.get("/calendar/days/" + date).then((r) => setD(r.data)).catch(() => setErr(true));
}, [expanded, date]);
useEffect(() => { if (expanded && ref.current) { try { ref.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (e) {} } }, [expanded]);
const net = agg ? agg.net_pnl_cents : (d && d.summary ? d.summary.net_pnl_cents : null);
const noTrades = agg == null && (!d || !d.summary);
const sm = d && d.summary;
const winsLosses = sm ? (sm.wins_count + " / " + sm.losses_count) : "—";
const statCells = sm ? [
[t("calendar.stat.total_trades"), {sm.trades_closed_count}],
[t("calendar.stat.gross"), ],
[t("calendar.stat.winners_losers"), {winsLosses}],
[t("calendar.stat.commissions"), ],
[t("calendar.stat.win_rate"), {fmtPct(sm.win_rate)}],
[t("calendar.stat.volume"), {(sm.volume_lots_x100 / 100).toFixed(2)}],
[t("calendar.stat.pf"), {sm.profit_factor == null ? "∞" : fmtNum(sm.profit_factor)}],
] : [];
return
{expanded &&
{err &&
{ setErr(false); setD(null); }} />}
{!err && !d &&
}
{!err && d && noTrades &&
{t("calendar.no_trades_day")}
}
{!err && d && !noTrades &&
{t("calendar.day_curve")}
{statCells.map((c, i) =>
)}
{d.trades && d.trades.length > 0 &&
| {t("trades.col_time")} | {t("trades.col_symbol")} | {t("trades.col_direction")} | {t("trades.col_pnl")} | {t("trades.col_r")} |
{d.trades.map((tr) => navigate("/app/trades/" + tr.trade_id)}>
| {fmtTime(tr.closed_at)} |
{tr.symbol} |
|
|
{fmtR(tr.r_multiple)} |
)}
}
}
}
;
}
/* mini calendar sticky (desktop) — grid สีตาม sign · คลิกวันที่มีเทรด → expand card */
function CalMiniCal({ t, ym, month, dmap, selected, onPick }) {
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 dd = 1; dd <= dim; dd++) cells.push(dd);
const weekdays = t("calendar.weekdays");
return
{weekdays.map((w, i) =>
{w}
)}
{cells.map((num, i) => {
if (!num) return
;
const date = month + "-" + pad2(num);
const dd = dmap[date];
const isSel = date === selected;
const net = dd ? dd.net_pnl_cents : null;
const dot = net == null ? null : net > 0 ? "var(--profit)" : net < 0 ? "var(--loss)" : "var(--ink-subtle)";
return
;
})}
;
}
function CalendarDesktop({ t, ym, data, sample, dayParam, toast }) {
const month = data.month;
const dmap = {}; (data.days || []).forEach((d) => { dmap[d.trade_date] = d; });
const currency = "USD";
const [expanded, setExpanded] = useState(dayParam && dmap[dayParam] ? dayParam : null);
// sync กับ ?day= (back/forward · deep-link)
useEffect(() => { setExpanded(dayParam && dmap[dayParam] ? dayParam : (dayParam && !dmap[dayParam] ? dayParam : null)); }, [dayParam, month]);
const toggle = (date) => { const next = expanded === date ? null : date; setExpanded(next); patchQuery({ day: next || null, month: month }); };
const pick = (date) => { setExpanded(date); patchQuery({ day: date, month: month }); };
// วันเรียงล่าสุดก่อน · ถ้า deep-link วันว่าง (ไม่มีเทรด) แทรกการ์ดว่างบนสุด
const dates = (data.days || []).map((d) => d.trade_date).sort().reverse();
const emptySelected = expanded && !dmap[expanded];
return
{emptySelected && toggle(expanded)} />}
{dates.map((date) => toggle(date)} />)}
;
}
function CalendarPage({ t, toast }) {
const isDesktop = useIsDesktop();
const [devState, setDevState] = useState("ready");
const [phase, setPhase] = useState("loading"); // loading|ready|empty|error|locked
const [data, setData] = useState(null);
const [meta, setMeta] = useState(null);
const [errCode, setErrCode] = useState(null);
const [accts, setAccts] = useState(null);
const q = getQuery();
const monthParam = q.get("month");
const dayParam = q.get("day");
const acct = q.get("account_id") || "";
const noAccounts = accts && accts.length === 0;
useEffect(() => { window.MTOS_API.get("/accounts").then((r) => setAccts(r.data)).catch(() => setAccts([])); }, []);
// strip ?day= ที่ผิด format / เป็นอนาคต (E10)
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);
const load = useCallback((sim, mparam) => {
setPhase("loading");
if (sim === "loading") return;
if (sim === "empty") { setTimeout(() => setPhase("empty"), 120); return; }
if (sim === "locked") {
window.MTOS_API.get("/calendar/sample").then((r) => { setData(r.data); setMeta(r.meta); setPhase("locked"); }).catch(() => { setErrCode("error"); setPhase("error"); });
return;
}
const ym = parseYM(mparam) ? mparam : ymNow();
window.MTOS_API.get("/calendar/" + ym, sim === "error" ? { simulate: "error" } : {})
.then((r) => { setData(r.data); setMeta(r.meta); setPhase(r.data.days && r.data.days.length ? "ready" : "empty"); })
.catch((e) => { setErrCode((e.envelope && e.envelope.error && e.envelope.error.code) || "error"); setPhase("error"); });
}, []);
useEffect(() => { load(devState === "ready" ? null : devState, monthParam); }, [devState, monthParam, acct, load]);
const ym = data ? (parseYM(data.month) || parseYM(ymNow())) : null;
return
{phase === "loading" &&
}
{phase === "error" &&
load(null, monthParam)} />}
{phase === "empty" && noAccounts ? navigate("/app/connect") : patchQuery({ quickadd: "1" })} />}
{(phase === "ready" || phase === "locked") && data && ym &&
{phase === "locked" &&
}
{isDesktop && phase === "ready"
?
:
{phase === "locked" && }
}
}
{/* sheet ใช้กับ MonthGrid (มือถือทุก phase · desktop เฉพาะ locked) · desktop ready → expand การ์ด (กัน portal หลุด) */}
{sheetOpen && !(isDesktop && phase === "ready") && patchQuery({ day: null })} toast={toast} />}
;
}
/* ===========================================================================
Public pages (login/register — mock)
=========================================================================== */
function AuthPage({ t, mode, onLogin }) {
const params = new URLSearchParams(window.location.search);
const next = params.get("next") || "/app";
const doLogin = () => { onLogin(); navigate(next, true); };
return ;
}
function NotFoundPage({ t }) {
return
404
{t("errors.not_found_page")}
{t("errors.not_found_page_body")}
;
}
/* ===========================================================================
LandingPage — route "/" (public · ไม่ต้อง session)
โครงย่อจาก marketing landing · CTA เดียวพาเข้า demo · light theme · mobile-first
copy: MT4 = อัปโหลด statement (ห้าม "sync") · ไม่มีราคา hardcode
=========================================================================== */
function EquityCurveSignature({ t }) {
// ลายเซ็น equity curve — SVG นิ่ง (0 external request) · แสดงผลอย่างเดียว
const line = "M0,176 L60,170 L120,180 L180,150 L240,160 L300,118 L360,130 L420,86 L480,96 L540,50 L600,36";
const area = line + " L600,200 L0,200 Z";
return
{t("landing.eq_title")}
{t("landing.eq_delta")}
{t("landing.eq_caption")}
;
}
function LandingPage({ t, lang, onSetLang, onDemo }) {
const goLogin = (e) => { e.preventDefault(); navigate("/login"); };
const goRegister = (e) => { e.preventDefault(); navigate("/register"); };
const feats = [
{ icon: "▦", k: "f1" }, { icon: "◎", k: "f2" }, { icon: "⇪", k: "f3" }, { icon: "◱", k: "f4" },
];
return
{/* top bar */}
{/* hero */}
{t("landing.eyebrow")}
{t("landing.hero_h1a")}
{t("landing.hero_h1b")}
{t("landing.hero_tagline")}
{t("landing.cta_note")}
{/* equity curve signature */}
{/* features */}
{t("landing.feat_h2")}
{feats.map((f) =>
{f.icon}
{t("landing." + f.k + "_t")}
{t("landing." + f.k + "_d")}
)}
{/* bottom CTA */}
;
}
/* ===========================================================================
Wave 2: Journal Core — shared journal components (EmotionPicker · TagPicker · TradeNoteBox)
ใช้ร่วมทั้ง Trade detail + Quick-add · optimistic write ผ่าน mock API
=========================================================================== */
const EMOTIONS = ["confident", "calm", "focused", "fomo", "fear", "greed", "revenge", "anxious", "impatient", "neutral"];
const EMOTION_EMOJI = { confident: "💪", calm: "😌", focused: "🎯", fomo: "🏃", fear: "😨", greed: "🤑", revenge: "😤", anxious: "😟", impatient: "⏱️", neutral: "😐" };
/* EmotionPicker — 10 emoji แถวเดียว scroll แนวนอน · 1 tap = บันทึก · แตะซ้ำ = ยกเลิก */
function EmotionPicker({ t, value, onPick }) {
return
{EMOTIONS.map((k) => {
const on = value === k;
return ;
})}
;
}
/* TradeNoteBox — autosave debounce 800ms → PUT journal · "บันทึกแล้ว ✓" */
function TradeNoteBox({ t, tradeId, initial }) {
const [note, setNote] = useState(initial || "");
const [state, setState] = useState(""); // "" | saving | saved
const timer = useRef(null);
useEffect(() => { setNote(initial || ""); setState(""); }, [tradeId]);
const onChange = (e) => {
const v = e.target.value.slice(0, 10000);
setNote(v); setState("saving");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
window.MTOS_API.put("/trades/" + tradeId + "/journal", { note_body: v }).then(() => setState("saved")).catch(() => setState(""));
}, 800);
};
return
{t("journal.note_title")}
{state === "saving" ? t("journal.note_saving") : state === "saved" ? t("journal.note_saved") + " ✓" : ""}
{note.length > 9000 &&
{t("journal.note_counter", { n: note.length })}
}
;
}
/* TagPicker — tag ระบบ (EA/Manual) lock · user tag toggle · สร้างใหม่ inline */
function TagPicker({ t, options, selected, onToggle, onCreate }) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const sel = {}; (selected || []).forEach((id) => { sel[id] = 1; });
const create = () => { const n = name.trim(); if (!n) return; onCreate(n); setName(""); setAdding(false); };
const sysSelected = (options || []).filter((x) => x.is_system && sel[x.public_id]);
const userTags = (options || []).filter((x) => !x.is_system);
return ;
}
/* badges — EA/Manual · ปิดบางส่วน · สถานะ open */
function OriginBadge({ t, origin }) {
const ea = origin === "ea";
return {ea ? t("trades.badge_ea") : t("trades.badge_manual")};
}
function PartialBadge({ t }) {
return {t("trades.partial_badge")};
}
function DirText({ t, dir }) {
return {dir === "long" ? "▲ " + t("filters.direction_long") : "▼ " + t("filters.direction_short")};
}
function fmtR(r) { return r === null || r === undefined ? "—" : (r >= 0 ? "+" : "−") + Math.abs(r).toFixed(2) + "R"; }
/* ===========================================================================
Wave 2: Trade log filter (FilterBar · FilterSheet · ActiveFilterChips · ResultSummary · ExcludedNotice)
ทุก dim → query string · back/forward re-parse (ผ่าน patchQuery + useRoute)
=========================================================================== */
function filterDims(t, opts) {
const all = {
symbol: { label: t("filters.dim_symbol"), values: (opts.symbols || []).map((s) => [s, s]) },
direction: { label: t("filters.dim_direction"), values: [["long", t("filters.direction_long")], ["short", t("filters.direction_short")]] },
session: { label: t("filters.dim_session"), values: (opts.sessions || []).map((s) => [s.key, t("session." + s.key)]) },
outcome: { label: t("filters.dim_outcome"), values: [["win", t("filters.outcome_win")], ["loss", t("filters.outcome_loss")], ["be", t("filters.outcome_be")]] },
origin: { label: t("filters.dim_origin"), values: [["ea", t("filters.origin_ea")], ["manual", t("filters.origin_manual")]] },
sort: { label: t("filters.dim_sort"), values: [["-closed_at", t("filters.sort_recent")], ["closed_at", t("filters.sort_oldest")], ["-net_cents", t("filters.sort_pnl_high")], ["net_cents", t("filters.sort_pnl_low")]] },
};
const enabled = opts.dims_enabled || Object.keys(all);
const out = {};
["symbol", "direction", "session", "outcome", "origin", "sort"].forEach((k) => { if (enabled.indexOf(k) >= 0) out[k] = all[k]; });
return out;
}
const FILTER_KEYS = ["symbol", "direction", "session", "outcome", "origin"]; // sort ไม่นับเป็น active chip
function activeCount(q) { let n = 0; FILTER_KEYS.forEach((k) => { if (q[k]) n++; }); return n; }
/* desktop inline chip ต่อ dim */
function DimChip({ t, dimKey, g, sel, onPick }) {
const [open, setOpen] = useState(false);
const cur = g.values.find((v) => v[0] === sel);
return setOpen((o) => !o)}>
{dimKey !== "sort" && }
{g.values.map((v) => )}
;
}
function FilterSheet({ t, open, onClose, dims, q }) {
const [draft, setDraft] = useState({});
useEffect(() => {
if (open) { const d = {}; Object.keys(dims).forEach((k) => { d[k] = q[k] || ""; }); setDraft(d); }
}, [open]);
const setD = (k, v) => setDraft((o) => Object.assign({}, o, { [k]: v }));
const apply = () => { const patch = { cursor: null }; Object.keys(dims).forEach((k) => { patch[k] = draft[k] || null; }); patchQuery(patch); onClose(); };
const clear = () => { const d = {}; Object.keys(dims).forEach((k) => { d[k] = ""; }); setDraft(d); };
return
{Object.keys(dims).map((dk) => { const g = dims[dk];
return
{g.label}
{dk !== "sort" && }
{g.values.map((v) => )}
;
})}
;
}
function ActiveFilterChips({ t, q }) {
const chips = [];
if (q.symbol) chips.push(["symbol", q.symbol]);
if (q.direction) chips.push(["direction", t("filters.direction_" + q.direction)]);
if (q.session) chips.push(["session", t("session." + q.session)]);
if (q.outcome) chips.push(["outcome", t("filters.outcome_" + q.outcome)]);
if (q.origin) chips.push(["origin", t("filters.origin_" + q.origin)]);
if (!chips.length) return null;
const clearAll = () => patchQuery({ symbol: null, direction: null, session: null, outcome: null, origin: null, cursor: null });
return
{chips.map(([k, label]) =>
{label}
)}
;
}
function TradeCard({ t, tr, onOpen }) {
const net = tr.net_cents;
return ;
}
/* ===========================================================================
Wave 2.5: Trades KPI strip — 4 ใบเหนือ list · เลขจาก /trades/summary (สะท้อน filter)
ทุกค่าจาก mock (client format/วาดเท่านั้น) · re-fetch เมื่อ filter/qkey เปลี่ยน
=========================================================================== */
/* donut 2 arc (PF) — เขียว=gross win · แดง=gross loss */
function PFDonut({ win, loss }) {
const tot = win + loss || 1;
const r = 15, c = 2 * Math.PI * r, gw = c * (win / tot);
return ;
}
/* half-gauge (win%) — semicircle · เขียว fill ตาม rate */
function WinGauge({ rate }) {
const r = rate == null ? 0 : Math.max(0, Math.min(1, rate));
const R = 22, cx = 26, cy = 26;
const p = (frac) => { const ang = Math.PI - Math.PI * frac; return [cx + R * Math.cos(ang), cy - R * Math.sin(ang)]; };
const s = p(0), e = p(r), bgEnd = p(1);
return ;
}
function KpiPill({ n, kind, label }) {
const map = { W: "var(--profit)", BE: "#3b82f6", L: "var(--loss)" };
const bg = { W: "#e7f8f0", BE: "#eef1fe", L: "#fdecef" };
return {kind} {n};
}
function TradesKPIStrip({ t, qkey }) {
const [s, setS] = useState(null);
const [phase, setPhase] = useState("loading"); // loading | ready | error
useEffect(() => {
let dead = false; setPhase("loading");
const sp = new URLSearchParams();
["symbol", "direction", "session", "outcome", "origin", "status", "account_id"].forEach((k) => { const v = getQuery().get(k); if (v) sp.set(k, v); });
const range = getQuery().get("range") || "30d";
const df = rangeToDateFrom(range); if (df) sp.set("date_from", df);
window.MTOS_API.get("/trades/summary?" + sp.toString())
.then((r) => { if (!dead) { setS(r.data); setPhase("ready"); } })
.catch(() => { if (!dead) setPhase("error"); });
return () => { dead = true; };
}, [qkey]);
if (phase === "error") return null;
if (phase === "loading" || !s) return
{[0, 1, 2, 3].map((i) =>
)}
;
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 = 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)";
return
{/* 1 · Net cumulative P&L */}
{t("trades.kpi.net")}
{fmtMoney(s.net_pnl_cents, s.currency)}
{t("trades.kpi.trade_count", { n: s.trades_closed })}
{/* 2 · Profit factor + donut */}
{/* 3 · Win % + half gauge + pills */}
{t("trades.kpi.winrate")}
{/* 4 · Avg win/loss + proportion bar */}
{t("trades.kpi.avgwl")}
{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)}
;
}
/* ===========================================================================
Wave 2: TradesPage — มือถือ การ์ด+infinite scroll · desktop ตาราง+hover row
=========================================================================== */
function TradesPage({ t, toast, rev }) {
const [devState, setDevState] = useState("ready");
const [opts, setOpts] = useState({ symbols: [], sessions: [], dims_enabled: [] });
const [rows, setRows] = useState([]);
const [meta, setMeta] = useState(null);
const [phase, setPhase] = useState("loading"); // loading|ready|empty_none|empty_filter|error|locked
const [loadingMore, setLoadingMore] = useState(false);
const [errCode, setErrCode] = useState(null);
const [sheetOpen, setSheetOpen] = useState(false);
const sentinel = useRef(null);
const q = getQuery();
const acct = q.get("account_id") || "";
const range = q.get("range") || "30d";
const qkey = ["symbol", "direction", "session", "outcome", "origin", "sort"].map((k) => k + "=" + (q.get(k) || "")).join("&") + "|acct=" + acct + "|range=" + range;
const filtersActive = activeCount({ symbol: q.get("symbol"), direction: q.get("direction"), session: q.get("session"), outcome: q.get("outcome"), origin: q.get("origin") }) > 0;
useEffect(() => { window.MTOS_API.get("/filter-options").then((r) => setOpts(r.data)).catch(() => {}); }, []);
const buildQuery = useCallback((cursor) => {
const sp = new URLSearchParams();
["symbol", "direction", "session", "outcome", "origin", "sort", "r_min", "r_max", "status"].forEach((k) => { const v = getQuery().get(k); if (v) sp.set(k, v); });
if (acct) sp.set("account_id", acct);
const df = rangeToDateFrom(range); if (df) sp.set("date_from", df);
sp.set("limit", "12");
if (cursor) sp.set("cursor", cursor);
return "/trades?" + sp.toString();
}, [acct, range]);
const load = useCallback((sim) => {
setPhase("loading"); setRows([]); setMeta(null);
if (sim === "loading") return;
if (sim === "locked") {
window.MTOS_API.get("/trades?limit=3").then((r) => { setRows(r.data); setMeta(r.meta); setPhase("locked"); }).catch(() => { setErrCode("error"); setPhase("error"); });
return;
}
const opt = sim === "error" ? { simulate: "error" } : sim === "empty" ? { simulate: "empty" } : {};
window.MTOS_API.get(buildQuery(null), opt).then((r) => {
setRows(r.data); setMeta(r.meta);
if (!r.data.length) setPhase(filtersActive ? "empty_filter" : "empty_none");
else setPhase("ready");
}).catch((e) => { setErrCode((e.envelope && e.envelope.error && e.envelope.error.code) || "error"); setPhase("error"); });
}, [buildQuery, filtersActive]);
useEffect(() => { load(devState === "ready" ? null : devState); }, [devState, qkey, rev]);
const loadMore = useCallback(() => {
if (!meta || !meta.next_cursor || loadingMore) return;
setLoadingMore(true);
window.MTOS_API.get(buildQuery(meta.next_cursor)).then((r) => {
setRows((prev) => prev.concat(r.data)); setMeta(r.meta); setLoadingMore(false);
}).catch(() => setLoadingMore(false));
}, [meta, loadingMore, buildQuery]);
// infinite scroll (IntersectionObserver)
useEffect(() => {
if (phase !== "ready") return;
const el = sentinel.current; if (!el) return;
const io = new IntersectionObserver((ents) => { if (ents[0].isIntersecting) loadMore(); }, { rootMargin: "200px" });
io.observe(el);
return () => io.disconnect();
}, [phase, loadMore]);
const dims = filterDims(t, opts);
const openTrade = (id) => navigate("/app/trades/" + id);
const listMobile =
{rows.map((tr) => openTrade(tr.trd_id)} />)}
;
const listDesktop =
| {t("trades.col_symbol")} | {t("trades.col_direction")} | {t("trades.col_lots")} |
{t("trades.col_pnl")} | {t("trades.col_r")} | {t("trades.col_time")} |
{rows.map((tr) => openTrade(tr.trd_id)}>
| {tr.symbol}{tr.is_partial && } |
|
{tr.lots.toFixed(2)} |
{tr.status === "open" ? {t("trades.status_open")} : } |
{fmtR(tr.r_multiple)} |
{fmtTime(tr.closed_at || tr.opened_at)} |
)}
;
return
{/* FilterBar sticky */}
{Object.keys(dims).map((dk) => patchQuery({ [dk]: v, cursor: null })} />)}
{/* KPI strip — สะท้อน filter slice (ซ่อนตอน error / locked / ยังไม่มีไม้เลย) */}
{(phase === "ready" || phase === "loading" || phase === "empty_filter") &&
}
{phase === "loading" &&
}
{phase === "error" &&
load(null)} />}
{phase === "empty_none" && patchQuery({ quickadd: "1" })} />}
{phase === "empty_filter" && patchQuery({ symbol: null, direction: null, session: null, outcome: null, origin: null, cursor: null })} />}
{phase === "locked" &&
{t("trades.sample_hint")}
{rows.map((tr) => {}} />)}
}
{phase === "ready" &&
{meta ? t("trades.result_summary", { count: meta.result_count, accounts: meta.account_count }) : ""}
{meta && meta.excluded_null_r > 0 &&
⚠ {t("trades.excluded_notice", { n: meta.excluded_null_r })}
}
{listMobile}
{listDesktop}
{loadingMore ? t("trades.loading_more") : meta && meta.next_cursor ? : t("trades.end_of_list")}
}
setSheetOpen(false)} dims={dims} q={{ symbol: q.get("symbol"), direction: q.get("direction"), session: q.get("session"), outcome: q.get("outcome"), origin: q.get("origin"), sort: q.get("sort") }} />
;
}
function rangeToDateFrom(range) {
if (!range || range === "all") return null;
const days = range === "7d" ? 7 : range === "90d" ? 90 : 30;
const now = new Date();
const base = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
base.setUTCDate(base.getUTCDate() - days);
return base.toISOString().slice(0, 10);
}
/* querystring กลางสำหรับ analytics calls — แนบ account_id + date range จาก URL (P0-3) */
function analyticsQS(acct, range) {
const sp = new URLSearchParams();
if (acct) sp.set("account_id", acct);
const df = rangeToDateFrom(range); if (df) sp.set("date_from", df);
const qs = sp.toString();
return qs ? "?" + qs : "";
}
/* ===========================================================================
Wave 2: TradeDetailPage — header + EmotionPicker + TradeNoteBox + TagPicker + legs + AI placeholder
=========================================================================== */
function TradeDetailPage({ t, id, toast }) {
const [phase, setPhase] = useState("loading"); // loading|ready|notfound|error
const [tr, setTr] = useState(null);
const [journal, setJournal] = useState({ emotion: null, tag_ids: [], note: { body: "" } });
const [tagOptions, setTagOptions] = useState([]);
useEffect(() => {
let dead = false; setPhase("loading");
window.MTOS_API.get("/trades/" + id).then((r) => {
if (dead) return; setTr(r.data); setJournal(r.data.journal || { emotion: null, tag_ids: [], note: { body: "" } }); setPhase("ready");
}).catch((e) => { if (dead) return; setPhase(e.status === 404 ? "notfound" : "error"); });
window.MTOS_API.get("/tags").then((r) => { if (!dead) setTagOptions(r.data); }).catch(() => {});
return () => { dead = true; };
}, [id]);
const curEmotion = journal && journal.emotion ? journal.emotion.emotion : null;
const setEmotion = (k) => {
const prev = journal.emotion;
setJournal((j) => Object.assign({}, j, { emotion: k ? { emotion: k } : null })); // optimistic
window.MTOS_API.put("/trades/" + id + "/journal", { emotion: k }).catch(() => { setJournal((j) => Object.assign({}, j, { emotion: prev })); toast(t("states.error_title")); });
};
const toggleTag = (tid) => {
const cur = journal.tag_ids || [];
const next = cur.indexOf(tid) >= 0 ? cur.filter((x) => x !== tid) : cur.concat([tid]);
setJournal((j) => Object.assign({}, j, { tag_ids: next }));
window.MTOS_API.put("/trades/" + id + "/journal", { tag_ids: next }).catch(() => {});
};
const createTag = (name) => {
window.MTOS_API.post("/tags", { name: name }).then((r) => { setTagOptions((o) => o.concat([r.data])); toggleTag(r.data.public_id); }).catch(() => {});
};
if (phase === "loading") return ;
if (phase === "error") return navigate("/app/trades/" + id)} />;
if (phase === "notfound") return
404
{t("journal.not_found_title")}
{t("journal.not_found_body")}
;
const isOpen = tr.status === "open";
const header =
{tr.symbol}
{tr.is_partial &&
}
{isOpen &&
{t("trades.status_open")}}
{tr.lots.toFixed(2)} lot · {t("session." + tr.session)}
{isOpen ? "—" : }
{fmtR(tr.r_multiple)}{tr.r_multiple === null && !isOpen ? " · " + t("journal.r_null_hint") : ""}
;
const legs =
{t("journal.legs_title")}
{(tr.legs || []).map((lg, i) =>
{lg.kind === "entry" ? t("journal.leg_entry") : t("journal.leg_exit")}
{lg.price} · {lg.lots.toFixed(2)} lot
{fmtTime(lg.at)}
)}
{tr.sl_price &&
{t("journal.f_sl")}: {tr.sl_price}
}
;
const ai =
✦
{t("journal.ai_title")}
{t("journal.ai_coming_soon")}
;
return
{header}
{t("journal.emotion_title")}
{t("journal.emotion_hint")}
{legs}
{ai}
;
}
/* ===========================================================================
Wave 2: QuickAddSheet — ≤6 field · validation ไทย · หลังบันทึก → EmotionPicker ต่อ (ปิด loop)
=========================================================================== */
function QuickAddSheet({ t, toast, onClose, onCreated }) {
const [step, setStep] = useState("form"); // form | emotion
const [newId, setNewId] = useState(null);
const [emotion, setEmotionSel] = useState(null);
const [f, setF] = useState({ symbol: "", direction: "long", lots: "", entry_price: "", entry_at: "", exit_price: "", exit_at: "", sl_price: "" });
const [stillOpen, setStillOpen] = useState(false);
const [err, setErr] = useState({});
const [sug, setSug] = useState([]);
const set = (k, v) => setF((o) => Object.assign({}, o, { [k]: v }));
useEffect(() => {
if (!f.symbol) { setSug([]); return; }
let dead = false;
window.MTOS_API.get("/symbols?q=" + encodeURIComponent(f.symbol)).then((r) => { if (!dead) setSug((r.data || []).filter((s) => s !== f.symbol.toUpperCase()).slice(0, 6)); }).catch(() => {});
return () => { dead = true; };
}, [f.symbol]);
const validate = () => {
const e = {};
if (!f.symbol.trim()) e.symbol = t("quickadd.err_symbol");
if (!(parseFloat(f.lots) > 0)) e.lots = t("quickadd.err_lots");
if (!(parseFloat(f.entry_price) > 0)) e.entry_price = t("quickadd.err_entry_price");
if (!f.entry_at) e.entry_at = t("quickadd.err_entry_time");
if (!stillOpen && !(parseFloat(f.exit_price) > 0)) e.exit_price = t("quickadd.err_exit_price");
setErr(e); return Object.keys(e).length === 0;
};
const save = () => {
if (!validate()) return;
const body = { symbol: f.symbol.toUpperCase(), direction: f.direction, lots: f.lots, entry_price: f.entry_price, entry_at: toIso(f.entry_at), sl_price: f.sl_price };
if (!stillOpen) { body.exit_price = f.exit_price; body.exit_at = toIso(f.exit_at); }
window.MTOS_API.post("/manual-entries", body).then((r) => {
setNewId(r.data.trd_id); if (onCreated) onCreated(r.data.trd_id);
toast(t("quickadd.saved_toast")); setStep("emotion");
}).catch(() => toast(t("states.error_title")));
};
const pickEmotion = (k) => { setEmotionSel(k); if (newId) window.MTOS_API.put("/trades/" + newId + "/journal", { emotion: k }).catch(() => {}); };
const field = (label, node, e) =>
{node}
{e &&
{e}
}
;
if (step === "emotion") return
{t("quickadd.emotion_step_title")}
{t("quickadd.emotion_step_hint")}
;
return
{field(t("quickadd.f_symbol"),
set("symbol", e.target.value)} placeholder={t("quickadd.f_symbol_ph")} autoComplete="off" />
{sug.length > 0 &&
{sug.map((s) => )}
}
, err.symbol)}
{field(t("quickadd.f_direction"),
{[["long", t("quickadd.buy")], ["short", t("quickadd.sell")]].map(([v, lbl]) => )}
)}
{field(t("quickadd.f_lots"),
set("lots", e.target.value)} placeholder="0.50" />, err.lots)}
{!stillOpen &&
}
{field(t("quickadd.f_sl"),
)}
;
}
function toIso(local) { if (!local) return new Date().toISOString(); const d = new Date(local); return isNaN(d) ? new Date().toISOString() : d.toISOString(); }
/* ===========================================================================
App root
=========================================================================== */
function App() {
const loc = useRoute();
const [lang, setLang] = useState(getLang());
const [cat, setCat] = useState(null);
const [catErr, setCatErr] = useState(false);
const [authed, setAuthed] = useState(Session.active());
const [toastMsg, setToastMsg] = useState(null);
const [listRev, setListRev] = useState(0); // bump เพื่อบังคับ TradesPage refetch หลัง quick-add
// i18n loader
useEffect(() => {
let dead = false;
setCatErr(false);
loadCatalog(lang).then((c) => { if (!dead) setCat(c); }).catch(() => { if (!dead) setCatErr(true); });
return () => { dead = true; };
}, [lang]);
const t = useCallback((key, params) => {
if (!cat) return key;
let s = cat[key];
if (s === undefined) { if (window.console) console.error("[i18n] missing key:", key); return key; }
if (params) for (const p in params) s = s.split("{" + p + "}").join(params[p]);
return s;
}, [cat]);
const onSetLang = (l) => { try { localStorage.setItem("mtos_lang", l); } catch (e) {} setLang(l); };
const toast = (m) => setToastMsg(m);
const onLogout = () => { Session.logout(); setAuthed(false); navigate("/", true); };
const onLogin = () => { Session.login(); setAuthed(true); };
const onDemo = () => { Session.startDemo(); setAuthed(true); navigate("/app", true); };
if (catErr) return
โหลดข้อความไม่สำเร็จ
;
if (!cat) return …
;
const pathname = window.location.pathname;
const route = matchRoute(pathname);
// landing (public) — มี session แล้วเข้า "/" → เด้งเข้าแอป
if (route.name === "landing") {
if (authed) { navigate("/app", true); return null; }
return ;
}
// public routes
if (route.name === "login" || route.name === "register") {
return
setToastMsg(null)} />
;
}
// SessionGuard
if (!authed) { navigate("/login?next=" + encodeURIComponent(pathname + window.location.search), true); return null; }
// onboarding = full-screen (นอก AppShell)
if (route.name === "onboarding") {
return
setToastMsg(null)} />
;
}
const qaOpen = getQuery().get("quickadd") === "1";
const closeQa = () => patchQuery({ quickadd: null });
let page;
if (route.name === "dashboard") page = ;
else if (route.name === "calendar") page = ;
else if (route.name === "trades") page = ;
else if (route.name === "trade_detail") page = ;
else if (route.name === "connect") page = ;
else if (route.name === "import") page = ;
else if (route.name === "insights") page = ;
else if (route.name === "unlock") page = ;
else if (route.name === "settings") page = ;
else if (route.name === "not_found") page = ;
else page = ;
return
patchQuery({ quickadd: "1" })}>
{page}
{qaOpen && setListRev((r) => r + 1)} />}
setToastMsg(null)} />
;
}
/* render ย้ายไป pages-w3.jsx (โหลดหลัง app.jsx) — เพื่อให้ W3 page components พร้อมก่อน render แรก */