const { useState, useEffect, useMemo, useCallback } = React; // ---------- design tokens ---------- const COLORS = { paper: "#EEF1EE", card: "#F8FAF7", ink: "#1E2A22", inkMuted: "#5B665D", forest: "#2F6F4E", forestSoft: "#E4EEE7", rust: "#B8492E", rustSoft: "#F5E5E0", brass: "#A9812E", brassSoft: "#F1E9D4", line: "#CDD5CA", lineSoft: "#E1E6DE", }; const FONT = { display: "'Space Grotesk', ui-sans-serif, sans-serif", mono: "'IBM Plex Mono', ui-monospace, monospace", body: "'IBM Plex Sans', ui-sans-serif, sans-serif", }; const QUOTES = [ "Plans survive contact with the market only if you follow them.", "The trade you skip is still a decision.", "Small losses are tuition. Big losses are lessons you didn't need.", "Boredom is not a signal to enter a trade.", "Your edge is a process, not a prediction.", "Cut the loss before it becomes a story you have to tell.", "Consistency beats intensity over a full month.", "The market doesn't know you're on a losing streak.", "Size the position for the plan, not for the feeling.", "A good exit is worth more than a good entry.", "Revenge trades are paid for twice.", "Write it down before you forget why it felt obvious.", "The best traders are boring on purpose.", "One bad day doesn't erase a good process.", "If it wasn't in the plan, it wasn't a setup.", "Discipline is remembering what you decided when you were calm.", ]; const MOODS = ["Confident", "Calm", "Anxious", "Frustrated", "Excited"]; const MOOD_COLORS = { Confident: { fg: COLORS.forest, bg: COLORS.forestSoft }, Calm: { fg: "#4C6B8A", bg: "#E4EAF0" }, Anxious: { fg: "#C97A2B", bg: "#F5E8D8" }, Frustrated: { fg: COLORS.rust, bg: COLORS.rustSoft }, Excited: { fg: "#8A4C6B", bg: "#F0E2EA" }, }; const WEEK_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const API_BASE = "/api"; // ---------- helpers ---------- function formatKey(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function todayKey() { return formatKey(new Date()); } function monthKey(d) { return formatKey(d).slice(0, 7); } function parseKey(key) { const [y, m, d] = key.split("-").map(Number); return new Date(y, m - 1, d); } function fmtMoney(n, opts = {}) { if (n === null || n === undefined || Number.isNaN(n)) return "—"; const sign = n < 0 ? "-" : opts.showPlus && n > 0 ? "+" : ""; return `${sign}$${Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; } function fmtDateHuman(key) { const d = parseKey(key); return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric", }); } function uid() { return (typeof crypto !== "undefined" && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36); } function cloneData(d) { return JSON.parse(JSON.stringify(d)); } function addMonths(date, n) { const d = new Date(date); d.setMonth(d.getMonth() + n); d.setDate(1); return d; } function buildMonthGrid(monthDate) { const y = monthDate.getFullYear(); const m = monthDate.getMonth(); const firstDay = new Date(y, m, 1); const startOffset = (firstDay.getDay() + 6) % 7; const daysInMon = new Date(y, m + 1, 0).getDate(); const cells = []; for (let i = 0; i < startOffset; i++) cells.push(null); for (let d = 1; d <= daysInMon; d++) cells.push(new Date(y, m, d)); while (cells.length % 7 !== 0) cells.push(null); const weeks = []; for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); return weeks; } // ---------- API layer ---------- async function apiLogin(password) { const res = await fetch(`${API_BASE}/login.php`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); return res.ok; } async function apiLogout() { try { await fetch(`${API_BASE}/logout.php`, { method: "POST", credentials: "include" }); } catch (e) { // ignore } } async function apiCheckSession() { try { const res = await fetch(`${API_BASE}/me.php`, { credentials: "include" }); if (!res.ok) return false; const data = await res.json(); return !!data.authed; } catch (e) { return false; } } async function apiGetState() { const res = await fetch(`${API_BASE}/state.php`, { credentials: "include" }); if (!res.ok) throw new Error("Failed to load"); return res.json(); } async function apiSaveState(state) { const res = await fetch(`${API_BASE}/state.php`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state), }); if (!res.ok) throw new Error("Failed to save"); return res.json(); } // ---------- icons (hand-drawn, no icon library needed) ---------- function Icon({ children, size = 18, color = "currentColor", strokeWidth = 1.8 }) { return ( {children} ); } const HomeIcon = (props) => ( ); const CalendarIcon = (props) => ( ); const JournalIcon = (props) => ( ); const PlusIcon = (props) => ( ); const XIcon = (props) => ( ); const ChevronLeftIcon = (props) => ( ); const ChevronRightIcon = (props) => ( ); const TrashIcon = (props) => ( ); const PencilIcon = (props) => ( ); const LoaderIcon = ({ size = 22, color = COLORS.ink }) => ( ); // ---------- hand-rolled equity chart (no chart library needed) ---------- function EquityChart({ data }) { const width = 320; const height = 200; const padding = { top: 10, right: 10, bottom: 20, left: 46 }; const innerW = width - padding.left - padding.right; const innerH = height - padding.top - padding.bottom; const allVals = data.reduce((acc, d) => acc.concat([d.equity, d.adjusted]), []); const min = Math.min.apply(null, allVals); const max = Math.max.apply(null, allVals); const range = max - min || 1; const pad = range * 0.15; const yMin = min - pad; const yMax = max + pad; const xFor = (i) => padding.left + (data.length <= 1 ? 0 : (i / (data.length - 1)) * innerW); const yFor = (v) => padding.top + innerH - ((v - yMin) / (yMax - yMin)) * innerH; function pathFor(key) { return data.map((d, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(1)} ${yFor(d[key]).toFixed(1)}`).join(" "); } const tickCount = 4; const ticks = Array.from({ length: tickCount + 1 }, (_, i) => yMin + ((yMax - yMin) * i) / tickCount); return (
{ticks.map((t, i) => ( {(t / 1000).toFixed(1)}k ))}
); } // ---------- auth screens ---------- function CenteredLoader() { return (
); } function LoginScreen({ onSuccess }) { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); async function submit(e) { e.preventDefault(); setBusy(true); setError(""); const ok = await apiLogin(password); setBusy(false); if (ok) { onSuccess(); } else { setError("Wrong password."); } } return (
Tally
Enter your password
setPassword(e.target.value)} autoFocus style={{ width: "100%", padding: "10px 12px", borderRadius: 8, border: `1px solid ${COLORS.line}`, fontFamily: FONT.mono, background: COLORS.card, color: COLORS.ink, marginBottom: 10, boxSizing: "border-box" }} /> {error &&
{error}
}
); } function Root() { const [authed, setAuthed] = useState(null); useEffect(() => { apiCheckSession().then(setAuthed); }, []); if (authed === null) return ; if (!authed) return setAuthed(true)} />; return { apiLogout(); setAuthed(false); }} />; } // ---------- main app ---------- function TradingJournalApp({ onLogout }) { const [data, setData] = useState({ trades: {}, days: {}, entries: {} }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [tab, setTab] = useState("home"); const [viewMonth, setViewMonth] = useState(() => { const d = new Date(); d.setDate(1); return d; }); const [openDate, setOpenDate] = useState(null); const [toast, setToast] = useState(null); const [confirmReset, setConfirmReset] = useState(false); const [journalFilter, setJournalFilter] = useState(""); useEffect(() => { (async () => { try { const remote = await apiGetState(); setData({ trades: remote.trades || {}, days: remote.days || {}, entries: remote.entries || {}, }); } catch (e) { // start empty if the very first load fails; it'll be created on first save } finally { setLoading(false); } })(); }, []); const persist = useCallback(async (next) => { setData(next); setSaving(true); try { await apiSaveState(next); } catch (e) { setToast({ type: "error", text: "Couldn't save — check your connection and try again." }); } finally { setSaving(false); } }, []); useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 3200); return () => clearTimeout(t); }, [toast]); const upsertTrade = useCallback( (dateKey, trade) => { const next = cloneData(data); next.trades[trade.id] = { ...trade, date: dateKey }; persist(next); }, [data, persist] ); const deleteTrade = useCallback( (id) => { const next = cloneData(data); delete next.trades[id]; persist(next); }, [data, persist] ); const setDayMeta = useCallback( (dateKey, patch) => { const next = cloneData(data); next.days[dateKey] = { ...(next.days[dateKey] || {}), ...patch }; persist(next); }, [data, persist] ); const upsertEntry = useCallback( (entry) => { const next = cloneData(data); next.entries[entry.id] = entry; persist(next); }, [data, persist] ); const deleteEntry = useCallback( (id) => { const next = cloneData(data); delete next.entries[id]; persist(next); }, [data, persist] ); const resetAll = useCallback(() => { persist({ trades: {}, days: {}, entries: {} }); setConfirmReset(false); setToast({ type: "ok", text: "All data cleared." }); }, [persist]); const currentMonthStr = monthKey(new Date()); const dashboardStats = useMemo(() => { let totalTrades = 0; let totalWins = 0; let activeDays = 0; let totalWithdrawal = 0; let totalDeposit = 0; Object.entries(data.days).forEach(([k, v]) => { if (!k.startsWith(currentMonthStr) || !v) return; const tc = Number(v.tradesCount || 0); if (tc > 0) activeDays += 1; totalTrades += tc; totalWins += Number(v.winsCount || 0); totalWithdrawal += Number(v.withdrawal || 0); totalDeposit += Number(v.deposit || 0); }); return { pl: totalWithdrawal - totalDeposit, winRate: totalTrades ? (totalWins / totalTrades) * 100 : null, avgPerDay: activeDays ? totalTrades / activeDays : null, total: totalTrades, }; }, [data.days, currentMonthStr]); const equitySeries = useMemo(() => { const now = new Date(); const y = now.getFullYear(); const m = now.getMonth(); const lastDay = new Date(y, m + 1, 0).getDate(); const endDay = Math.min(now.getDate(), lastDay); let lastEquity = null; let cumWithdrawal = 0; const series = []; for (let day = 1; day <= endDay; day++) { const key = formatKey(new Date(y, m, day)); const meta = data.days[key]; if (meta && meta.equity !== undefined && meta.equity !== null && meta.equity !== "") { lastEquity = Number(meta.equity); } cumWithdrawal += Number((meta && meta.withdrawal) || 0); if (lastEquity !== null) { series.push({ day, label: String(day), equity: lastEquity, adjusted: lastEquity + cumWithdrawal, }); } } return series; }, [data.days]); const equityDiffByDate = useMemo(() => { const entries = Object.entries(data.days) .filter(([, v]) => v && v.equity !== undefined && v.equity !== null && v.equity !== "") .map(([k, v]) => [k, Number(v.equity)]) .sort((a, b) => (a[0] < b[0] ? -1 : 1)); const map = {}; for (let i = 1; i < entries.length; i++) { const [key, eq] = entries[i]; const [, prevEq] = entries[i - 1]; map[key] = eq - prevEq; } return map; }, [data.days]); const weeks = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]); const tradesByDate = useMemo(() => { const map = {}; Object.values(data.trades).forEach((t) => { map[t.date] = map[t.date] || []; map[t.date].push(t); }); return map; }, [data.trades]); const entriesByDate = useMemo(() => { const map = {}; Object.values(data.entries).forEach((e) => { map[e.date] = e; }); return map; }, [data.entries]); const monthRollup = useMemo(() => { const prefix = monthKey(viewMonth); let deposit = 0; let withdrawal = 0; Object.entries(data.days).forEach(([k, v]) => { if (k.startsWith(prefix)) { deposit += Number(v.deposit || 0); withdrawal += Number(v.withdrawal || 0); } }); return { deposit, withdrawal }; }, [data.days, viewMonth]); const quote = useMemo(() => QUOTES[Math.floor(Math.random() * QUOTES.length)], []); const openDayTrades = openDate ? tradesByDate[openDate] || [] : []; const openDayMeta = openDate ? data.days[openDate] || {} : {}; const openDayEntry = openDate ? entriesByDate[openDate] : null; if (loading) return ; return (
{tab === "home" && ( )} {tab === "calendar" && ( setViewMonth((d) => addMonths(d, -1))} onNext={() => setViewMonth((d) => addMonths(d, 1))} weeks={weeks} entriesByDate={entriesByDate} daysMeta={data.days} equityDiffByDate={equityDiffByDate} rollup={monthRollup} onSelectDate={setOpenDate} /> )} {tab === "journal" && ( setOpenDate(todayKey())} filter={journalFilter} setFilter={setJournalFilter} /> )}
{openDate && ( setOpenDate(null)} dayMeta={openDayMeta} trades={openDayTrades} entry={openDayEntry} onSaveDayMeta={setDayMeta} onUpsertTrade={upsertTrade} onDeleteTrade={deleteTrade} onUpsertEntry={upsertEntry} onDeleteEntry={deleteEntry} /> )}
); } // ---------- top-level chrome ---------- function TopBar({ saving }) { return (
Tally
{saving ? "Saving…" : new Date().toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })}
); } function BottomNav({ tab, setTab }) { const items = [ { id: "home", label: "Home", Icon: HomeIcon }, { id: "calendar", label: "Calendar", Icon: CalendarIcon }, { id: "journal", label: "Journal", Icon: JournalIcon }, ]; return (
{items.map(({ id, label, Icon }) => ( ))}
); } function ToastBanner({ toast }) { if (!toast) return null; return (
{toast.text}
); } // ---------- dashboard ---------- function Dashboard({ stats, equitySeries, quote, confirmReset, setConfirmReset, resetAll, onLogout }) { return (
Today's reminder
"{quote}"
0 ? "pos" : stats.pl < 0 ? "neg" : "flat"} />
Equity — this month
{equitySeries.length === 0 ? (
No equity logged yet this month. Add today's equity from the Calendar to start the line.
) : ( )}
{!confirmReset ? ( ) : (
Delete everything?
)}
); } function StatCard({ label, value, tone }) { const color = tone === "pos" ? COLORS.forest : tone === "neg" ? COLORS.rust : COLORS.ink; return (
{label}
{value}
); } function LegendDot({ color, label, dashed }) { return (
{label}
); } // ---------- calendar ---------- function CalendarView({ viewMonth, onPrev, onNext, weeks, entriesByDate, daysMeta, equityDiffByDate, rollup, onSelectDate }) { const monthLabel = viewMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" }); const today = todayKey(); return (
{monthLabel}
Deposits: {rollup.deposit ? "-" : ""}{fmtMoney(rollup.deposit)} P/L: = 0 ? COLORS.forest : COLORS.rust }}>{fmtMoney(rollup.withdrawal - rollup.deposit, { showPlus: true })} Withdrawals: {rollup.withdrawal ? "+" : ""}{fmtMoney(rollup.withdrawal)}
{WEEK_LABELS.map((w) => (
{w}
))}
{weeks.map((week, wi) => (
{week.map((date, di) => { if (!date) return
; const key = formatKey(date); const meta = daysMeta[key] || {}; const hasEntry = !!entriesByDate[key]; const isToday = key === today; const diff = equityDiffByDate[key]; const hasEquity = meta.equity !== undefined && meta.equity !== ""; return ( ); })}
))}
); } // ---------- day detail modal ---------- function DayDetailModal({ dateKey, onClose, dayMeta, trades, entry, onSaveDayMeta, onUpsertTrade, onDeleteTrade, onUpsertEntry, onDeleteEntry }) { const [equity, setEquity] = useState(dayMeta.equity ?? ""); const [deposit, setDeposit] = useState(dayMeta.deposit ?? ""); const [withdrawal, setWithdrawal] = useState(dayMeta.withdrawal ?? ""); const [tradesCount, setTradesCount] = useState(dayMeta.tradesCount ?? ""); const [winsCount, setWinsCount] = useState(dayMeta.winsCount ?? ""); const [addingTrade, setAddingTrade] = useState(false); const [editingTrade, setEditingTrade] = useState(null); const [editingEntry, setEditingEntry] = useState(false); function commitMeta(patch) { onSaveDayMeta(dateKey, patch); } return (
e.stopPropagation()} style={{ width: "100%", maxWidth: 480, borderTopLeftRadius: 20, borderTopRightRadius: 20, overflowY: "auto", background: COLORS.paper, border: `1px solid ${COLORS.line}`, maxHeight: "88vh" }} >
{fmtDateHuman(dateKey)}
Equity & cash
commitMeta({ equity: equity === "" ? undefined : Number(equity) })} /> commitMeta({ deposit: deposit === "" ? undefined : Number(deposit) })} tone="neg" /> commitMeta({ withdrawal: withdrawal === "" ? undefined : Number(withdrawal) })} tone="pos" />
Saved automatically. Withdrawals count as your real gain here — deposits don't.
Trade count
commitMeta({ tradesCount: tradesCount === "" ? undefined : Number(tradesCount) })} /> commitMeta({ winsCount: winsCount === "" ? undefined : Number(winsCount) })} tone="pos" />
This is what Home's win rate and trade averages are based on.
Trade details (optional)
{trades.length === 0 && !addingTrade && (
No detailed trades for this day — optional, for the ones worth writing up.
)} {trades.map((t) => ( { setEditingTrade(t); setAddingTrade(true); }} onDelete={() => onDeleteTrade(t.id)} /> ))} {addingTrade && ( { setAddingTrade(false); setEditingTrade(null); }} onSave={(trade) => { onUpsertTrade(dateKey, { ...trade, id: trade.id || uid() }); setAddingTrade(false); setEditingTrade(null); }} /> )}
Journal
{!entry && !editingEntry && ( )} {entry && !editingEntry && ( setEditingEntry(true)} onDelete={() => onDeleteEntry(entry.id)} /> )} {editingEntry && ( setEditingEntry(false)} onSave={(e) => { onUpsertEntry(e); setEditingEntry(false); }} /> )}
); } function SectionLabel({ children }) { return (
{children}
); } function FieldNumber({ label, value, onChange, onBlur, tone }) { const color = tone === "pos" ? COLORS.forest : tone === "neg" ? COLORS.rust : COLORS.ink; return ( ); } function TextField({ label, value, onChange, placeholder, textarea }) { const Comp = textarea ? "textarea" : "input"; return ( ); } function TradeRow({ trade, onEdit, onDelete }) { const pl = Number(trade.pl) || 0; const color = trade.result === "win" ? COLORS.forest : trade.result === "loss" ? COLORS.rust : COLORS.inkMuted; return (
{fmtMoney(pl, { showPlus: true })}
); } function TradeForm({ initial, onSave, onCancel }) { const [result, setResult] = useState((initial && initial.result) || "win"); const [pl, setPl] = useState(initial && initial.pl !== undefined ? initial.pl : ""); const [showMore, setShowMore] = useState(!!(initial && (initial.symbol || initial.notes))); const [symbol, setSymbol] = useState((initial && initial.symbol) || ""); const [direction, setDirection] = useState((initial && initial.direction) || "buy"); const [lotSize, setLotSize] = useState(initial && initial.lotSize !== undefined ? initial.lotSize : ""); const [entryPrice, setEntryPrice] = useState(initial && initial.entryPrice !== undefined ? initial.entryPrice : ""); const [exitPrice, setExitPrice] = useState(initial && initial.exitPrice !== undefined ? initial.exitPrice : ""); const [notes, setNotes] = useState((initial && initial.notes) || ""); function submit() { if (pl === "") return; onSave({ id: initial ? initial.id : undefined, result, pl: Number(pl), symbol: symbol || undefined, direction: showMore ? direction : undefined, lotSize: lotSize === "" ? undefined : Number(lotSize), entryPrice: entryPrice === "" ? undefined : Number(entryPrice), exitPrice: exitPrice === "" ? undefined : Number(exitPrice), notes: notes || undefined, }); } return (
{["win", "loss", "breakeven"].map((r) => ( ))}
{}} /> {showMore && (
{}} />
{}} /> {}} />
)}
); } function EntryForm({ initial, dateKey, onCancel, onSave }) { const [title, setTitle] = useState((initial && initial.title) || ""); const [body, setBody] = useState((initial && initial.body) || ""); const [mood, setMood] = useState((initial && initial.mood) || ""); const [tags, setTags] = useState(((initial && initial.tags) || []).join(", ")); const [screenshot, setScreenshot] = useState((initial && initial.screenshot) || ""); const [imgError, setImgError] = useState(false); function submit() { if (!title && !body) return; onSave({ id: initial ? initial.id : uid(), date: dateKey, title, body, mood: mood || undefined, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), screenshot: screenshot.trim() || undefined, }); } return (
{MOODS.map((m) => { const c = MOOD_COLORS[m]; return ( ); })}
{ setScreenshot(v); setImgError(false); }} placeholder="https://..." /> {screenshot && !imgError && ( Screenshot preview setImgError(true)} /> )} {screenshot && imgError && (
Couldn't load an image from that link.
)}
); } function EntryDisplay({ entry, onEdit, onDelete }) { return (
{entry.title || "Untitled entry"}
{entry.body &&
{entry.body}
} {entry.screenshot && Journal screenshot}
{entry.mood && ( {entry.mood} )} {(entry.tags || []).map((t) => ( #{t} ))}
); } // ---------- journal list ---------- function JournalView({ entries, onOpenDate, onNewEntry, filter, setFilter }) { const sorted = [...entries].sort((a, b) => (a.date < b.date ? 1 : -1)); const filtered = filter ? sorted.filter((e) => (e.title + " " + e.body + " " + (e.tags || []).join(" ")).toLowerCase().includes(filter.toLowerCase())) : sorted; return (
setFilter(e.target.value)} placeholder="Search entries..." style={{ flex: 1, borderRadius: 8, padding: "8px 12px", fontSize: 14, border: `1px solid ${COLORS.line}`, fontFamily: FONT.body, background: COLORS.card, color: COLORS.ink }} />
{filtered.length === 0 ? (
{entries.length === 0 ? "No journal entries yet. Write about a trade worth remembering." : "No entries match that search."}
) : (
{filtered.map((e) => ( ))}
)}
); } // ---------- mount ---------- const root = ReactDOM.createRoot(document.getElementById("root")); root.render();