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 (
);
}
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 (
);
}
// ---------- 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 (
);
}
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 (
);
}
// ---------- 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 (
);
}
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 (
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" }}
>
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 && (
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 &&

}
{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 (
{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();