// Chart Growth Hub — Student Trading Journal (premium). // // Table: journal_entries (+ lot_size, stop_loss, take_profit, strategy_tag, // session_tag, emotion, followed_plan, signal_id). RLS: a student reads/writes // only their own rows; staff have a SELECT-only policy so a coach can review // but can never edit or delete a student's record of their own trades. // // The "Private note" lives in a separate table (journal_private) with no staff // policy at all. RLS cannot be scoped to one column, so the only way to keep a // note out of a staff `select *` is to keep it out of the row entirely. // // Logging is open to every student. VIP gates the pro analytics block and the // branded PDF export — that gating is presentational only, enforced here. // // Exposes window.JournalPanel({client, session, isVIP, onNeedVip, member, // prefill, onPrefillUsed}). (function(){ const { useState, useEffect, useMemo, useRef } = React; /* ── constants ───────────────────────────────────────────────────────────── */ const STRATS = [ {v:'A', label:'Strategy A — EMA Trend'}, {v:'B', label:'Strategy B — ADX Momentum'}, {v:'C', label:'Strategy C — StochRSI Reversal'}, {v:'D', label:'Strategy D — SMI Supply & Demand'}, {v:'manual', label:'Manual / my own setup'}, ]; const STRAT_SHORT = {A:'A',B:'B',C:'C',D:'D',manual:'Manual'}; // Session windows in PH time (UTC+8) — the same three the landing page and the // Session Timings tab publish. Used to auto-suggest a session tag. const SESSIONS = [ {v:'asian', label:'Asian (7:00 AM PH)', from:7*60, to:11*60}, {v:'london', label:'London (4:00 PM PH)', from:16*60, to:20*60}, {v:'ny', label:'New York (9:30 PM PH)', from:21*60+30,to:25*60+30}, {v:'other', label:'Outside a main session',from:-1, to:-1}, ]; const SESSION_SHORT = {asian:'Asian',london:'London',ny:'New York',other:'Other'}; const EMOTIONS = [ {v:'calm', label:'Calm & patient'}, {v:'confident', label:'Confident'}, {v:'fomo', label:'FOMO — chased it'}, {v:'fear', label:'Fearful / hesitant'}, {v:'greed', label:'Greedy — oversized'}, {v:'revenge', label:'Revenge trading'}, {v:'tired', label:'Tired / unfocused'}, ]; const EMOTION_SHORT = {calm:'Calm',confident:'Confident',fomo:'FOMO',fear:'Fear',greed:'Greed',revenge:'Revenge',tired:'Tired'}; // Which moods count as a red flag in the discipline read-out. const BAD_MOOD = {fomo:1, greed:1, revenge:1, tired:1}; const RANGES = [ {v:'all', label:'All time', days:0}, {v:'7', label:'Last 7 days', days:7}, {v:'30', label:'Last 30 days', days:30}, {v:'90', label:'Last 90 days', days:90}, ]; /* ── helpers ─────────────────────────────────────────────────────────────── */ const num = v => (v===null || v===undefined || v==='') ? null : Number(v); const fmtDate = d => d ? new Date(d+'T00:00:00').toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}) : '—'; const money = n => (n===null||n===undefined||n==='') ? '—' : (Number(n)>=0?'+':'−') + '$' + Math.abs(Number(n)).toFixed(2); const money0 = n => (Number(n)>=0?'+':'−') + '$' + Math.abs(Number(n)).toFixed(2); // Signed R multiple from price levels. Needs entry, exit and a stop, because R // is defined against the risk actually taken, not against the P&L. function rMultiple(r){ const e = num(r.entry_price), x = num(r.exit_price), sl = num(r.stop_loss); if(e===null || x===null || sl===null) return null; const risk = Math.abs(e - sl); if(!(risk > 0)) return null; // guards 0 and NaN const dir = r.direction === 'short' ? -1 : 1; return ((x - e) * dir) / risk; } // Today in PH time, as YYYY-MM-DD. Same trick used across the site: shift the // epoch by +8h and then read the UTC fields, so the answer never depends on the // device timezone. PH has no DST. function phToday(){ const d = new Date(Date.now() + 8*3600000); return d.toISOString().slice(0,10); } function phMinutesNow(){ const d = new Date(Date.now() + 8*3600000); return d.getUTCHours()*60 + d.getUTCMinutes(); } function guessSession(){ const m = phMinutesNow(); for(const s of SESSIONS){ if(s.from < 0) continue; // NY spills past midnight, so compare against the wrapped minute too. if((m >= s.from && m < s.to) || (m + 1440 >= s.from && m + 1440 < s.to)) return s.v; } return 'other'; } function daysAgoISO(n){ const d = new Date(Date.now() + 8*3600000 - n*86400000); return d.toISOString().slice(0,10); } function blankForm(){ return { trade_date: phToday(), pair:'XAU/USD', direction:'long', lot_size:'', entry_price:'', exit_price:'', stop_loss:'', take_profit:'', pnl:'', strategy_tag:'', session_tag: guessSession(), emotion:'', followed_plan:null, notes:'', private_note:'', signal_id:null }; } /* ── one-time stylesheet ─────────────────────────────────────────────────── */ // Injected once rather than inlined per element, because the responsive // table/card swap and the print layout both need real media queries, which // inline styles cannot express. (function injectCSS(){ if(document.getElementById('cg-journal-css')) return; const s = document.createElement('style'); s.id = 'cg-journal-css'; s.textContent = ` .jr-actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap} .jr-btn-sm{cursor:pointer;padding:8px 13px;border-radius:9px;border:1px solid var(--border); background:var(--surface);color:var(--ink-dim);font-size:.78rem;font-weight:600; display:inline-flex;align-items:center;gap:7px;transition:border-color .18s,color .18s} .jr-btn-sm:hover{border-color:var(--border-gold);color:var(--gold-2)} .jr-btn-sm svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8; stroke-linecap:round;stroke-linejoin:round} .jr-menu{position:absolute;right:0;top:calc(100% + 6px);z-index:40;min-width:230px; background:var(--navy-950);border:1px solid var(--border-gold);border-radius:11px; box-shadow:0 18px 44px rgba(0,0,0,.55);overflow:hidden} .jr-menu button{display:block;width:100%;text-align:left;padding:11px 14px;background:none; border:0;color:var(--ink-dim);font-size:.8rem;cursor:pointer;font-family:inherit} .jr-menu button:hover:not(:disabled){background:rgba(212,175,55,.09);color:var(--gold-2)} .jr-menu button:disabled{opacity:.45;cursor:not-allowed} .jr-menu .jr-menu-sub{display:block;font-size:.7rem;color:var(--ink-faint);margin-top:2px} .jr-filters{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-bottom:18px} .jr-filters select{background:var(--surface);border:1px solid var(--border);color:var(--ink-dim); border-radius:9px;padding:8px 11px;font-size:.78rem;font-family:inherit;cursor:pointer} .jr-filters select:focus{outline:none;border-color:var(--border-gold)} .jr-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px} .jr-stat{background:linear-gradient(165deg,var(--surface-2),var(--surface)); border:1px solid var(--border);border-radius:var(--radius);padding:15px 17px} .jr-stat .v{font-family:var(--font-mono);font-size:1.34rem;font-weight:600;color:var(--gold-2);line-height:1.15} .jr-stat .l{font-size:.75rem;color:var(--ink-dim);margin-top:3px} @media(max-width:820px){ .jr-stats{grid-template-columns:repeat(2,1fr)} } .jr-pro{border:1px solid var(--border-gold);border-radius:var(--radius); background:linear-gradient(165deg,rgba(212,175,55,.05),transparent 60%),var(--navy-950); padding:20px 22px;margin-bottom:20px} .jr-metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:18px} .jr-metric{padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:var(--surface)} .jr-metric .v{font-family:var(--font-mono);font-size:.98rem;font-weight:600} .jr-metric .l{font-size:.68rem;color:var(--ink-faint);margin-top:2px;text-transform:uppercase;letter-spacing:.04em} @media(max-width:820px){ .jr-metrics{grid-template-columns:repeat(2,1fr)} } .jr-split{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:18px} @media(max-width:820px){ .jr-split{grid-template-columns:1fr} } .jr-bd{border:1px solid var(--border);border-radius:10px;overflow:hidden} .jr-bd h4{margin:0;padding:10px 13px;font-size:.76rem;text-transform:uppercase;letter-spacing:.06em; color:var(--ink-faint);border-bottom:1px solid var(--border);background:var(--surface)} .jr-bd table{width:100%;border-collapse:collapse} .jr-bd td{padding:8px 13px;font-size:.79rem;border-bottom:1px solid var(--border)} .jr-bd tr:last-child td{border-bottom:0} .jr-bd td.n{font-family:var(--font-mono);text-align:right;white-space:nowrap} .jr-table{width:100%;border-collapse:separate;border-spacing:0; border:1px solid var(--border);border-radius:var(--radius);overflow:hidden} .jr-table th{font-size:.68rem;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint); text-align:left;padding:10px 12px;background:var(--surface);border-bottom:1px solid var(--border); cursor:pointer;white-space:nowrap;user-select:none} .jr-table th:hover{color:var(--gold-2)} .jr-table td{padding:11px 12px;font-size:.82rem;border-bottom:1px solid var(--border);vertical-align:top} .jr-table tr:last-child td{border-bottom:0} .jr-table tbody tr:hover{background:rgba(212,175,55,.035)} .jr-table td.n{font-family:var(--font-mono);white-space:nowrap} .jr-pill{font-family:var(--font-mono);font-size:.64rem;text-transform:uppercase;letter-spacing:.03em; padding:2px 7px;border-radius:999px;border:1px solid var(--border);white-space:nowrap;display:inline-block} .jr-cards{display:grid;gap:10px} .jr-card{padding:14px 16px;border:1px solid var(--border);border-radius:var(--radius-sm); background:linear-gradient(165deg,var(--surface-2),var(--surface))} /* Desktop shows the dense table; phones get the card list. */ .jr-desktop{display:block} .jr-mobile{display:none} @media(max-width:760px){ .jr-desktop{display:none} .jr-mobile{display:block} } /* view switch */ .jr-seg{display:inline-flex;border:1px solid var(--border);border-radius:9px;overflow:hidden} .jr-seg button{background:none;border:0;padding:8px 14px;font-size:.78rem;font-weight:600; color:var(--ink-faint);cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:6px} .jr-seg button+button{border-left:1px solid var(--border)} .jr-seg button.on{background:rgba(212,175,55,.12);color:var(--gold-2)} .jr-seg svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8; stroke-linecap:round;stroke-linejoin:round} /* month calendar */ .jr-cal{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:16px} .jr-cal-head{display:flex;align-items:center;gap:8px;padding:12px 14px;background:var(--surface); border-bottom:1px solid var(--border);flex-wrap:wrap} .jr-cal-title{font-weight:700;font-size:.95rem;min-width:150px} .jr-cal-sum{margin-left:auto;font-family:var(--font-mono);font-size:.82rem;font-weight:600} .jr-cal-dow{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));background:var(--surface)} .jr-cal-dow>div{padding:7px 6px;text-align:center;font-size:.64rem;text-transform:uppercase; letter-spacing:.07em;color:var(--ink-faint)} .jr-cal-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:5px;padding:8px;background:var(--navy-950)} .jr-cal-pad{min-height:74px} .jr-cal-day{position:relative;min-width:0;min-height:74px;border:1px solid var(--border);border-radius:9px; padding:6px 7px;display:flex;flex-direction:column;align-items:flex-start;gap:2px; cursor:pointer;font-family:inherit;text-align:left;transition:border-color .15s,transform .12s} .jr-cal-day:hover{border-color:var(--border-gold);transform:translateY(-1px)} .jr-cal-day.sel{box-shadow:0 0 0 1px var(--gold-2) inset} .jr-cal-day:focus-visible{outline:2px solid var(--gold-2);outline-offset:1px} .jr-cal-num{font-family:var(--font-mono);font-size:.72rem;color:var(--ink-faint)} .jr-cal-pnl{font-family:var(--font-mono);font-size:.78rem;font-weight:700;margin-top:auto; white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .jr-cal-pnl-s{display:none;font-family:var(--font-mono);font-size:.7rem;font-weight:700; margin-top:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .jr-cal-n{font-size:.62rem;color:var(--ink-faint);white-space:nowrap} .jr-cal-flag{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%; background:var(--red);box-shadow:0 0 0 2px var(--navy-950)} @media(max-width:640px){ .jr-cal-grid{gap:3px;padding:5px} .jr-cal-day,.jr-cal-pad{min-height:58px} .jr-cal-day{padding:4px 5px;border-radius:7px} .jr-cal-pnl{display:none} .jr-cal-pnl-s{display:block} .jr-cal-n{display:none} .jr-cal-sum{width:100%;margin-left:0;order:9} } .jr-day{border:1px solid var(--border-gold);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px; background:linear-gradient(165deg,rgba(212,175,55,.05),transparent 60%),var(--navy-950)} .jr-day-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px} .jr-lock{display:flex;gap:16px;align-items:center;justify-content:space-between;flex-wrap:wrap; padding:20px 22px;border:1px dashed var(--border-gold);border-radius:var(--radius); background:var(--surface);margin-bottom:20px} `; document.head.appendChild(s); })(); /* ── analytics ───────────────────────────────────────────────────────────── */ // One pass over the filtered rows. Everything downstream (cards, curve, // breakdowns, PDF) reads from this so the numbers can never disagree. function analyse(rows){ const withPnl = rows.filter(r => num(r.pnl) !== null); const wins = withPnl.filter(r => Number(r.pnl) > 0); const losses= withPnl.filter(r => Number(r.pnl) < 0); const gross = wins.reduce((s,r)=>s+Number(r.pnl),0); const bad = Math.abs(losses.reduce((s,r)=>s+Number(r.pnl),0)); const total = withPnl.reduce((s,r)=>s+Number(r.pnl),0); const rs = rows.map(rMultiple).filter(v => v !== null); const avgR = rs.length ? rs.reduce((a,b)=>a+b,0)/rs.length : null; const planned = rows.filter(r => r.followed_plan !== null && r.followed_plan !== undefined); const kept = planned.filter(r => r.followed_plan === true).length; // Chronological order for the streak and the equity curve; the list itself is // newest-first, so this must sort its own copy rather than reuse it. const chrono = withPnl.slice().sort((a,b)=> a.trade_date === b.trade_date ? String(a.created_at||'').localeCompare(String(b.created_at||'')) : String(a.trade_date).localeCompare(String(b.trade_date))); let streak = 0, streakWin = null; for(let i = chrono.length - 1; i >= 0; i--){ const w = Number(chrono[i].pnl) > 0; if(streakWin === null){ streakWin = w; streak = 1; } else if(w === streakWin) streak++; else break; } let cum = 0; const curve = chrono.map(r => { cum += Number(r.pnl); return { d:r.trade_date, v:cum }; }); return { count: rows.length, scored: withPnl.length, winRate: withPnl.length ? Math.round(wins.length/withPnl.length*100) : null, total, // No losses at all means the ratio is undefined, not infinite — show a dash // rather than an "∞" that looks like a bug. profitFactor: bad > 0 ? gross/bad : null, avgWin: wins.length ? gross/wins.length : null, avgLoss: losses.length ? bad/losses.length : null, best: withPnl.length ? Math.max(...withPnl.map(r=>Number(r.pnl))) : null, worst: withPnl.length ? Math.min(...withPnl.map(r=>Number(r.pnl))) : null, avgR, rCount: rs.length, discipline: planned.length ? Math.round(kept/planned.length*100) : null, disciplineOf: planned.length, streak, streakWin, curve, }; } // win-rate / P&L split by an arbitrary key, used for both breakdown tables. function bucket(rows, key, labels){ const out = {}; rows.forEach(r => { const k = r[key] || '—'; if(!out[k]) out[k] = { k, n:0, wins:0, scored:0, pnl:0 }; const b = out[k]; b.n++; const p = num(r.pnl); if(p !== null){ b.scored++; b.pnl += p; if(p > 0) b.wins++; } }); return Object.values(out) .map(b => ({...b, label: labels[b.k] || b.k, wr: b.scored ? Math.round(b.wins/b.scored*100) : null})) .sort((a,b) => b.pnl - a.pnl); } /* ── equity curve ────────────────────────────────────────────────────────── */ function EquityCurve({curve, height}){ const H = height || 168, W = 720, PAD = 6; if(!curve.length) return
Log a trade with a P&L to see your equity curve.
; const vals = curve.map(p=>p.v); let lo = Math.min(0, ...vals), hi = Math.max(0, ...vals); if(hi === lo){ hi = lo + 1; } // flat line still needs a range const span = hi - lo; const x = i => PAD + (curve.length === 1 ? (W-2*PAD)/2 : i*(W-2*PAD)/(curve.length-1)); const y = v => PAD + (hi - v)*(H-2*PAD)/span; const pts = curve.map((p,i)=> x(i)+','+y(p.v)).join(' '); const up = vals[vals.length-1] >= 0; const col = up ? 'var(--green)' : 'var(--red)'; const zero = y(0); return ; } /* ── month calendar ──────────────────────────────────────────────────────── */ // trade_date is a plain 'YYYY-MM-DD' string, never a timestamp. Every date // calculation below is done on numbers or on UTC, never with a local-time // Date — `new Date('2026-08-01')` is midnight UTC, which is the previous day // for anyone west of Greenwich and would shift the whole grid by one cell. const MONTHS = ['January','February','March','April','May','June', 'July','August','September','October','November','December']; const DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; // Phone calendar cells are ~44px wide. Drop the currency symbol and the cents, // and fold anything past 999 into a k suffix, so the number still fits. function shortMoney(n){ const v = Number(n), a = Math.abs(v), sign = v < 0 ? '\u2212' : '+'; if(a >= 10000) return sign + Math.round(a/1000) + 'k'; if(a >= 1000) return sign + (a/1000).toFixed(1).replace(/\.0$/,'') + 'k'; return sign + Math.round(a); } const pad2 = n => (n < 10 ? '0' : '') + n; function ymd(y, m, d){ return y + '-' + pad2(m+1) + '-' + pad2(d); } // m is 0-based function daysInMonth(y, m){ return new Date(Date.UTC(y, m+1, 0)).getUTCDate(); } function firstDow(y, m){ return new Date(Date.UTC(y, m, 1)).getUTCDay(); } // 0 = Sunday function addMonth(ym, delta){ let y = ym.y, m = ym.m + delta; y += Math.floor(m/12); m = ((m % 12) + 12) % 12; return { y, m }; } function phMonth(){ const d = new Date(Date.now() + 8*3600000); return { y: d.getUTCFullYear(), m: d.getUTCMonth() }; } function monthOf(iso){ return { y: Number(iso.slice(0,4)), m: Number(iso.slice(5,7)) - 1 }; } function inMonth(iso, ym){ return Number(iso.slice(0,4)) === ym.y && Number(iso.slice(5,7)) - 1 === ym.m; } function MonthCalendar({rows, ym, onPrev, onNext, onToday, selected, onSelect}){ // Roll the month's trades up per day once, rather than filtering the full // list inside every one of the ~35 cells. const byDay = useMemo(()=>{ const m = {}; rows.forEach(r => { const k = r.trade_date; if(!m[k]) m[k] = { n:0, pnl:0, scored:0, offPlan:0 }; const b = m[k]; b.n++; const p = num(r.pnl); if(p !== null){ b.scored++; b.pnl += p; } if(r.followed_plan === false) b.offPlan++; }); return m; },[rows]); const dim = daysInMonth(ym.y, ym.m); const lead = firstDow(ym.y, ym.m); const today = phToday(); // Colour intensity is relative to the biggest day of this month, so a quiet // month is still readable instead of washing out to a single flat tone. const peak = Math.max(1, ...Object.keys(byDay) .filter(k => inMonth(k, ym)) .map(k => Math.abs(byDay[k].pnl))); const cells = []; for(let i = 0; i < lead; i++) cells.push(
); for(let d = 1; d <= dim; d++){ const key = ymd(ym.y, ym.m, d); const b = byDay[key]; const isToday = key === today; const isSel = key === selected; let bg = 'transparent', bd = 'var(--border)'; if(b && b.scored){ const t = Math.min(1, Math.abs(b.pnl)/peak); const a = (0.10 + 0.30*t).toFixed(3); bg = b.pnl > 0 ? 'rgba(74,222,128,'+a+')' : b.pnl < 0 ? 'rgba(248,113,113,'+a+')' : 'transparent'; bd = b.pnl > 0 ? 'rgba(74,222,128,.4)' : b.pnl < 0 ? 'rgba(248,113,113,.4)' : 'var(--border)'; } cells.push( ); } const monthRows = rows.filter(r => inMonth(r.trade_date, ym)); const monthPnl = monthRows.reduce((s,r)=> s + (num(r.pnl)||0), 0); return
{MONTHS[ym.m]} {ym.y}
0?'var(--green)':monthPnl<0?'var(--red)':'var(--ink-faint)'}}>{money0(monthPnl)} · {monthRows.length} {monthRows.length===1?'trade':'trades'}
{DOW.map(d=>
{d}
)}
{cells}
; } /* ── export ──────────────────────────────────────────────────────────────── */ function csvCell(v){ const s = (v===null||v===undefined) ? '' : String(v); // Leading =,+,-,@ are treated as a formula by Excel; prefix to neutralise. const safe = /^[=+\-@]/.test(s) ? "'" + s : s; return '"' + safe.replace(/"/g,'""') + '"'; } function downloadCSV(rows, name){ const head = ['Date','Pair','Direction','Lots','Entry','Stop','Target','Exit','P&L','R', 'Strategy','Session','Mood','Followed plan','Notes']; const body = rows.map(r => [ r.trade_date, r.pair, r.direction, r.lot_size, r.entry_price, r.stop_loss, r.take_profit, r.exit_price, r.pnl, rMultiple(r)===null ? '' : rMultiple(r).toFixed(2), STRAT_SHORT[r.strategy_tag]||'', SESSION_SHORT[r.session_tag]||'', EMOTION_SHORT[r.emotion]||'', r.followed_plan===true?'Yes':r.followed_plan===false?'No':'', r.notes||'', ].map(csvCell).join(',')); // BOM so Excel reads the UTF-8 currency and arrow glyphs correctly. const blob = new Blob(['' + [head.map(csvCell).join(','), ...body].join('\r\n')], {type:'text/csv;charset=utf-8'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name + '.csv'; document.body.appendChild(a); a.click(); setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 0); } const esc = s => String(s===null||s===undefined?'':s) .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); // Branded report. Rendered into a hidden same-origin iframe and printed from // there — window.open() gets killed by popup blockers even on a click, and an // iframe keeps the dashboard itself out of the print output. function buildReportHTML(rows, st, meta){ const curveSVG = (function(){ const c = st.curve; if(!c.length) return ''; const H=150,W=700,P=5; const vals=c.map(p=>p.v); let lo=Math.min(0,...vals), hi=Math.max(0,...vals); if(hi===lo) hi=lo+1; const span=hi-lo; const x=i=>P+(c.length===1?(W-2*P)/2:i*(W-2*P)/(c.length-1)); const y=v=>P+(hi-v)*(H-2*P)/span; const pts=c.map((p,i)=>x(i)+','+y(p.v)).join(' '); const up=vals[vals.length-1]>=0, col=up?'#15803d':'#b91c1c'; return '' + '' + ''; })(); const statCell = (l,v) => '
'+esc(v)+'
'+esc(l)+'
'; const trs = rows.map(r => { const R = rMultiple(r); const p = num(r.pnl); return '' + ''+esc(fmtDate(r.trade_date))+'' + ''+esc(r.pair)+'' + ''+esc(r.direction==='short'?'Short':'Long')+'' + ''+esc(r.lot_size??'')+'' + ''+esc(r.entry_price??'')+'' + ''+esc(r.stop_loss??'')+'' + ''+esc(r.exit_price??'')+'' + ''+esc(p===null?'':money0(p))+'' + ''+esc(R===null?'':(R>=0?'+':'')+R.toFixed(2)+'R')+'' + ''+esc(STRAT_SHORT[r.strategy_tag]||'')+'' + ''+esc(SESSION_SHORT[r.session_tag]||'')+'' + ''+esc(r.notes||'')+'' + ''; }).join(''); const bdRows = (arr) => arr.map(b => ''+esc(b.label)+''+b.n+'' + (b.wr===null?'—':b.wr+'%')+''+esc(money0(b.pnl))+'').join(''); return '' + esc(meta.filename) + '' + '
' + '

Trading Journal Statement

Chart Growth Hub · ' + esc(meta.rangeLabel) + '
' + '
' + esc(meta.name) + '' + esc(meta.memberId) + '
' + '
' + statCell('Trades', st.count) + statCell('Win rate', st.winRate===null?'—':st.winRate+'%') + statCell('Net P&L', money0(st.total)) + statCell('Profit factor', st.profitFactor===null?'—':st.profitFactor.toFixed(2)) + statCell('Avg R', st.avgR===null?'—':(st.avgR>=0?'+':'')+st.avgR.toFixed(2)+'R') + statCell('Discipline', st.discipline===null?'—':st.discipline+'%') + '
' + (curveSVG ? '

Equity curve

'+curveSVG+'
' : '') + '
' + '

By strategy

' + '' + (bdRows(meta.byStrat) || '') + '
StrategyTradesWin rateP&L
No tagged trades.
' + '

By session

' + '' + (bdRows(meta.bySess) || '') + '
SessionTradesWin rateP&L
No tagged trades.
' + '
' + '' + '' + '' + (trs || '') + '
DatePairDirLotsEntryStopExitP&LRStratSessionNotes
No trades in this range.
' + '
Generated ' + esc(meta.generated) + 'chartgrowthhub.com · private to ' + esc(meta.name) + '
' + ''; } function printReport(html){ const old = document.getElementById('cg-journal-print'); if(old) old.remove(); const f = document.createElement('iframe'); f.id = 'cg-journal-print'; f.setAttribute('aria-hidden','true'); // Off-screen rather than display:none — a display:none iframe does not lay // out in some browsers and prints blank. f.style.cssText = 'position:fixed;left:-10000px;top:0;width:1024px;height:768px;border:0'; document.body.appendChild(f); const d = f.contentDocument; d.open(); d.write(html); d.close(); const go = () => { try { f.contentWindow.focus(); f.contentWindow.print(); } catch(e){ /* printing blocked — the iframe is harmless, just leave it */ } }; // Give the logo a beat to load so it is not missing from the first page. if(d.readyState === 'complete') setTimeout(go, 350); else f.onload = () => setTimeout(go, 350); } /* ── small pieces ────────────────────────────────────────────────────────── */ function Stat({label, value, tone}){ return
{value}
{label}
; } function Metric({label, value, tone}){ return
{value}
{label}
; } function Breakdown({title, rows, empty}){ return

{title}

{rows.length === 0 ?
{empty}
: {rows.map(b=>( ))}
{b.label} {b.n} {b.wr===null?'—':b.wr+'%'} 0?'var(--green)':b.pnl<0?'var(--red)':'var(--ink-faint)'}}>{money0(b.pnl)}
}
; } /* ── trade list ──────────────────────────────────────────────────────────── */ // Shared by the list view and the calendar's day panel, so a trade renders // identically wherever it is reached from. `showSort` is off inside the day // panel — a single day has nothing worth re-sorting. function TradeList({rows, sort, onSort, onEdit, onDel, showSort}){ const toggleSort = k => { if(showSort && onSort) onSort(k); }; return {/* desktop table */}
{[['trade_date','Date'],['pair','Pair'],['lot_size','Lots'],['entry_price','Entry'], ['exit_price','Exit'],['pnl','P&L'],['r','R'],['strategy_tag','Strategy'],['session_tag','Session']] .map(([k,l])=>( ))} {rows.map(r=>{ const R = rMultiple(r), p = num(r.pnl); return ; })}
toggleSort(k)} style={showSort?null:{cursor:'default'}}> {l}{showSort && sort.k===k ? (sort.dir===1?' ↑':' ↓') : ''}Notes
{fmtDate(r.trade_date)}
{r.pair}
{r.direction==='short'?'Short':'Long'}
{r.lot_size??'—'} {r.entry_price??'—'} {r.exit_price??'—'} 0?'var(--green)':p<0?'var(--red)':'var(--ink-faint)'}}>{money(r.pnl)} =0?'var(--green)':'var(--red)'}}> {R===null?'—':(R>=0?'+':'')+R.toFixed(2)+'R'} {r.strategy_tag ? {STRAT_SHORT[r.strategy_tag]} : } {SESSION_SHORT[r.session_tag]||'—'} {r.notes||''} {(r.emotion || r.followed_plan!==null) &&
{r.emotion && {EMOTION_SHORT[r.emotion]}} {r.followed_plan===true && plan kept} {r.followed_plan===false && off plan}
}
{/* mobile cards */}
{rows.map(r=>{ const R = rMultiple(r), p = num(r.pnl); return
{r.pair} {r.direction==='short'?'Short':'Long'} {r.strategy_tag && {STRAT_SHORT[r.strategy_tag]}}
{fmtDate(r.trade_date)}{r.session_tag?' · '+SESSION_SHORT[r.session_tag]:''}
0?'var(--green)':p<0?'var(--red)':'var(--ink-faint)'}}>{money(r.pnl)}
{R!==null &&
=0?'var(--green)':'var(--red)'}}>{(R>=0?'+':'')+R.toFixed(2)}R
}
{(r.entry_price!==null||r.exit_price!==null||r.stop_loss!==null) &&
{r.entry_price!==null?'entry '+r.entry_price:''} {r.stop_loss!==null?' sl '+r.stop_loss:''} {r.exit_price!==null?' exit '+r.exit_price:''} {r.lot_size!==null?' '+r.lot_size+' lots':''}
} {r.notes &&
{r.notes}
} {(r.emotion || r.followed_plan!==null) &&
{r.emotion && {EMOTION_SHORT[r.emotion]}} {r.followed_plan===true && plan kept} {r.followed_plan===false && off plan}
}
; })}
; } /* ── main panel ──────────────────────────────────────────────────────────── */ function JournalPanel({client, session, isVIP, onNeedVip, member, prefill, onPrefillUsed}){ const [rows,setRows] = useState(null); const [err,setErr] = useState(''); const [form,setForm] = useState(null); const [busy,setBusy] = useState(false); const [menu,setMenu] = useState(false); const [range,setRange] = useState('all'); const [fStrat,setFStrat]= useState(''); const [fSess,setFSess] = useState(''); const [fRes,setFRes] = useState(''); const [sort,setSort] = useState({k:'trade_date', dir:-1}); const [view,setView] = useState('list'); // 'list' | 'calendar' const [ym,setYm] = useState(null); // visible month, null until rows land const [day,setDay] = useState(null); // selected day, 'YYYY-MM-DD' or null const menuRef = useRef(null); async function load(){ const { data, error } = await client.from('journal_entries').select('*') .eq('student_id', session.user.id) .order('trade_date',{ascending:false}).order('created_at',{ascending:false}); if(error) setErr(error.message); else { setRows(data||[]); setErr(''); } } useEffect(()=>{ load(); /* eslint-disable-next-line */ },[]); // Land on the month of the most recent trade rather than on today — a student // opening the calendar after two quiet weeks should still see their work. useEffect(()=>{ if(ym || !rows) return; setYm(rows.length ? monthOf(rows[0].trade_date) : phMonth()); /* eslint-disable-next-line */ },[rows]); // Close the export menu on an outside click. useEffect(()=>{ if(!menu) return; const h = e => { if(menuRef.current && !menuRef.current.contains(e.target)) setMenu(false); }; document.addEventListener('mousedown',h); return ()=>document.removeEventListener('mousedown',h); },[menu]); // A signal handed over from the Signals tab opens a pre-filled form. useEffect(()=>{ if(!prefill) return; setForm(Object.assign(blankForm(), prefill)); if(onPrefillUsed) onPrefillUsed(); /* eslint-disable-next-line */ },[prefill]); const filtered = useMemo(()=>{ let r = rows || []; const days = view==='list' ? ((RANGES.find(x=>x.v===range)||{}).days || 0) : 0; if(days){ const from = daysAgoISO(days); r = r.filter(x => x.trade_date >= from); } if(fStrat) r = r.filter(x => x.strategy_tag === fStrat); if(fSess) r = r.filter(x => x.session_tag === fSess); if(fRes === 'win') r = r.filter(x => num(x.pnl) !== null && Number(x.pnl) > 0); if(fRes === 'loss') r = r.filter(x => num(x.pnl) !== null && Number(x.pnl) < 0); if(fRes === 'open') r = r.filter(x => num(x.pnl) === null); return r; },[rows, range, fStrat, fSess, fRes, view]); // Everything below the toolbar reads from `scoped`, so the stat cards, the // equity curve, the breakdowns and the export can never describe a different // set of trades than the one on screen. const scoped = useMemo(()=> (view==='calendar' && ym) ? filtered.filter(r=>inMonth(r.trade_date, ym)) : filtered, [filtered, view, ym]); const stats = useMemo(()=>analyse(scoped),[scoped]); const byStrat = useMemo(()=>bucket(scoped.filter(r=>r.strategy_tag),'strategy_tag',STRAT_SHORT),[scoped]); const bySess = useMemo(()=>bucket(scoped.filter(r=>r.session_tag),'session_tag',SESSION_SHORT),[scoped]); // Trades on the selected day, oldest first — a day reads as a sequence. const dayRows = useMemo(()=> !day ? [] : filtered.filter(r=>r.trade_date===day) .slice().sort((a,b)=>String(a.created_at||'').localeCompare(String(b.created_at||''))), [filtered, day]); const sorted = useMemo(()=>{ const r = scoped.slice(); const k = sort.k, d = sort.dir; r.sort((a,b)=>{ let x = a[k], y = b[k]; if(k === 'pnl' || k === 'lot_size'){ x = num(x); y = num(y); } if(k === 'r'){ x = rMultiple(a); y = rMultiple(b); } // Unscored rows sink to the bottom in either direction — they carry no // information and would otherwise dominate the top of an ascending sort. if(x === null || x === undefined || x === '') return 1; if(y === null || y === undefined || y === '') return -1; if(typeof x === 'number' && typeof y === 'number') return (x-y)*d; return String(x).localeCompare(String(y))*d; }); return r; },[scoped, sort]); function toggleSort(k){ setSort(s => s.k===k ? {k, dir:-s.dir} : {k, dir:-1}); } const rangeLabel = (view==='calendar' && ym) ? MONTHS[ym.m] + ' ' + ym.y : (RANGES.find(x=>x.v===range)||RANGES[0]).label; const memberName = (member && (member.nickname || member.full_name)) || (session.user.email||'Member'); const memberId = (member && member.member_id) || ''; function exportCSV(){ setMenu(false); downloadCSV(sorted, 'CGH-journal-' + phToday()); } function exportPDF(){ setMenu(false); if(!isVIP){ if(onNeedVip) onNeedVip('The branded PDF statement is a VIP feature.'); return; } const logo = new URL('assets/brand/cgh-256.png', document.baseURI).href; printReport(buildReportHTML(sorted, stats, { filename:'CGH Trading Journal', logo, name:memberName, memberId, rangeLabel, byStrat, bySess, generated: new Date().toLocaleString(undefined,{dateStyle:'medium',timeStyle:'short'}), })); } function openAdd(onDate){ const f = blankForm(); if(typeof onDate === 'string') f.trade_date = onDate; setForm(f); } async function openEdit(r){ const f = Object.assign({}, blankForm(), r); ['lot_size','entry_price','exit_price','stop_loss','take_profit','pnl'].forEach(k=>{ f[k] = r[k] === null || r[k] === undefined ? '' : r[k]; }); f.strategy_tag = r.strategy_tag || ''; f.session_tag = r.session_tag || ''; f.emotion = r.emotion || ''; f.notes = r.notes || ''; f.private_note = ''; setForm(f); const { data } = await client.from('journal_private').select('body').eq('entry_id', r.id).maybeSingle(); if(data) setForm(cur => cur && cur.id === r.id ? {...cur, private_note: data.body || ''} : cur); } async function save(){ if(!form.pair.trim()){ setErr('Enter the pair or asset.'); return; } setErr(''); setBusy(true); const payload = { student_id: session.user.id, trade_date: form.trade_date, pair: form.pair.trim().toUpperCase(), direction: form.direction, lot_size: num(form.lot_size), entry_price: num(form.entry_price), exit_price: num(form.exit_price), stop_loss: num(form.stop_loss), take_profit: num(form.take_profit), pnl: num(form.pnl), strategy_tag: form.strategy_tag || null, session_tag: form.session_tag || null, emotion: form.emotion || null, followed_plan: form.followed_plan, signal_id: form.signal_id || null, notes: (form.notes||'').trim() || null, }; let error, id = form.id; if(id){ ({ error } = await client.from('journal_entries').update(payload).eq('id', id)); } else { const res = await client.from('journal_entries').insert(payload).select('id').single(); error = res.error; id = res.data && res.data.id; } if(!error && id){ const body = (form.private_note||'').trim(); if(body) await client.from('journal_private').upsert({entry_id:id, student_id:session.user.id, body, updated_at:new Date().toISOString()}); else await client.from('journal_private').delete().eq('entry_id', id); } setBusy(false); if(error){ setErr(error.message); return; } setForm(null); load(); } async function del(r){ if(!confirm('Delete this journal entry? This cannot be undone.')) return; setRows(rs => rs.filter(x => x.id !== r.id)); const { error } = await client.from('journal_entries').delete().eq('id', r.id); if(error){ setErr(error.message); load(); } } if(!rows) return
; const set = (k,v) => setForm(f => ({...f, [k]:v})); return
{/* header */}

Trading Journal

Log every trade and let the numbers show you what actually works. Your coach and CGH HQ can review your entries to mentor you — the private note on each trade stays yours alone.

{menu &&
}
{/* filters */}
{view==='list' && } {((view==='list'&&range!=='all')||fStrat||fSess||fRes) && } {scoped.length} of {rows.length} {rows.length===1?'entry':'entries'}
{err &&
{err}
} {/* headline stats — everyone */}
0?'var(--green)':stats.total<0?'var(--red)':null}/>
{/* calendar view */} {view==='calendar' && ym && { setYm(m=>addMonth(m,-1)); setDay(null); }} onNext={()=>{ setYm(m=>addMonth(m, 1)); setDay(null); }} onToday={()=>{ setYm(phMonth()); setDay(null); }} onSelect={setDay}/> {day &&
{fmtDate(day)}
{dayRows.length>0 && a+(num(r.pnl)||0),0)>0 ? 'var(--green)' : dayRows.reduce((a,r)=>a+(num(r.pnl)||0),0)<0 ? 'var(--red)' : 'var(--ink-faint)'}}> {money0(dayRows.reduce((a,r)=>a+(num(r.pnl)||0),0))} } {dayRows.length} {dayRows.length===1?'trade':'trades'}
{dayRows.length===0 ?
Nothing logged on this day yet.
: }
} {scoped.length===0 && !form &&
No trades in {MONTHS[ym.m]} {ym.y}{(fStrat||fSess||fRes)?' matching these filters':''}.
}
} {/* pro analytics — VIP */} {isVIP ?

Performance

VIP {rangeLabel}

Cumulative P&L across your logged trades.

=0?'+':'')+stats.avgR.toFixed(2)+'R'} tone={stats.avgR===null?'var(--ink-faint)':stats.avgR>=0?'var(--green)':'var(--red)'}/>
{stats.rCount < stats.count &&

R is calculated on the {stats.rCount} of {stats.count} {stats.count===1?'trade':'trades'} that have an entry, exit and stop loss.

}
{/* discipline */}
Discipline score
{stats.discipline===null ? 'Answer “Did I follow my plan?” when you log a trade to start tracking this.' : 'You followed your plan on '+stats.discipline+'% of your last '+stats.disciplineOf+' answered trades.'}
=80?'var(--green)':stats.discipline>=60?'var(--gold-2)':'var(--red)'}}> {stats.discipline===null?'—':stats.discipline+'%'}
{stats.discipline!==null &&
=80?'var(--green)':stats.discipline>=60?'var(--gold-2)':'var(--red)'}}/>
}
:

Performance analytics

VIP

Your equity curve, average R, profit factor, a win-rate breakdown by strategy and session, your discipline score, and the branded PDF statement. Keep logging — your entries are saved and every metric fills in the moment you upgrade.

} {/* form */} {form &&
{form.id?'Edit trade':'Log a trade'}
set('trade_date',e.target.value)}/>
set('pair',e.target.value)} placeholder="XAU/USD"/>
set('lot_size',e.target.value)} placeholder="0.08"/>
set('entry_price',e.target.value)}/>
set('exit_price',e.target.value)}/>
set('stop_loss',e.target.value)}/>
set('take_profit',e.target.value)}/>
set('pnl',e.target.value)} placeholder="120 or -45"/>
{[[true,'Yes'],[false,'No'],[null,'Skip']].map(([v,l])=>( ))}
set('notes',e.target.value)} placeholder="Setup, reasoning, what you'd do differently…"/>
set('private_note',e.target.value)} placeholder="Kept out of your coach's view entirely."/>
} {/* list view */} {view==='list' && {sorted.length===0 && !form &&
{rows.length===0 ? <>No entries yet. Click + Log a trade to start your journal. : <>No trades match these filters.}
} {sorted.length>0 && }
}
; } window.JournalPanel = JournalPanel; // Exposed for the coach/admin read-only view and for tests. window.cgJournalAnalyse = analyse; window.cgJournalBucket = bucket; window.cgJournalR = rMultiple; })();