From c6027b8a8041df11b861b274e752b7823296aa67 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Fri, 3 Jul 2026 22:16:01 -0400 Subject: [PATCH] feat: add EmotionLogger component with quick-add buttons and stats --- app/src/components/EmotionLogger.tsx | 261 +++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 app/src/components/EmotionLogger.tsx diff --git a/app/src/components/EmotionLogger.tsx b/app/src/components/EmotionLogger.tsx new file mode 100644 index 0000000..939eb9d --- /dev/null +++ b/app/src/components/EmotionLogger.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useClosureStore } from "@/stores/closure-store"; + +// --- Quick-add emotion definitions (ADR-0007: no imperative trade verbs) --- + +interface EmotionOption { + label: string; + emoji: string; + color: string; + bgColor: string; + borderColor: string; +} + +const EMOTION_OPTIONS: EmotionOption[] = [ + { label: "Frustrated", emoji: "πŸ”΄", color: "text-red-400", bgColor: "bg-red-500/10", borderColor: "border-red-500/30" }, + { label: "FOMO", emoji: "🟠", color: "text-orange-400", bgColor: "bg-orange-500/10", borderColor: "border-orange-500/30" }, + { label: "Revenge", emoji: "🟣", color: "text-purple-400", bgColor: "bg-purple-500/10", borderColor: "border-purple-500/30" }, + { label: "Patient", emoji: "πŸ”΅", color: "text-blue-400", bgColor: "bg-blue-500/10", borderColor: "border-blue-500/30" }, + { label: "Confused", emoji: "βšͺ", color: "text-gray-400", bgColor: "bg-gray-500/10", borderColor: "border-gray-500/30" }, + { label: "Confident", emoji: "🟒", color: "text-green-400", bgColor: "bg-green-500/10", borderColor: "border-green-500/30" }, + { label: "Anxious", emoji: "🟑", color: "text-yellow-400", bgColor: "bg-yellow-500/10", borderColor: "border-yellow-500/30" }, + { label: "Greedy", emoji: "🟀", color: "text-amber-600", bgColor: "bg-amber-700/10", borderColor: "border-amber-600/30" }, +]; + +// --- Stats helpers --- + +interface Stats { + totalToday: number; + mostCommon: { label: string; count: number } | null; + avgDeltaFromEntry: number | null; +} + +function computeStats( + logs: { timestamp: number; priceAtEvent: number }[], + entryPrice: number | null, +): Stats { + const now = Date.now(); + const todayStart = new Date(now).setHours(0, 0, 0, 0); + + const todayLogs = logs.filter((l) => l.timestamp >= todayStart); + const totalToday = todayLogs.length; + + // Most common emotion (from all logs) + const emotionCounts = new Map(); + for (const log of logs) { + emotionCounts.set(log.emotion, (emotionCounts.get(log.emotion) ?? 0) + 1); + } + let mostCommon: { label: string; count: number } | null = null; + let maxCount = 0; + for (const [label, count] of emotionCounts.entries()) { + if (count > maxCount) { + mostCommon = { label, count }; + maxCount = count; + } + } + + // Average price delta from entry + let avgDelta: number | null = null; + if (entryPrice != null && logs.length > 0) { + const deltas = logs.map((l) => l.priceAtEvent - entryPrice); + avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length; + } + + return { totalToday, mostCommon, avgDelta }; +} + +// --- Format helpers --- + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function formatPrice(p: number): string { + return p.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +// --- Component --- + +export function EmotionLogger() { + const emotionLogs = useClosureStore((s) => s.emotionLogs); + const addEmotionLog = useClosureStore((s) => s.addEmotionLog); + const removeEmotionLog = useClosureStore((s) => s.removeEmotionLog); + const activeExecutionId = useClosureStore((s) => s.activeExecutionId); + + // Local form state + const [selectedEmotion, setSelectedEmotion] = useState(""); + const [priceInput, setPriceInput] = useState(""); + const [noteInput, setNoteInput] = useState(""); + + // Read entry price from store if available (we'll look for it in the closure store) + const entryPrice = useClosureStore((s) => (s as any).entryPrice ?? null); + + const stats = useMemo(() => computeStats(emotionLogs, entryPrice), [emotionLogs, entryPrice]); + + // Quick-add handler + const handleQuickAdd = (emotion: string) => { + if (!priceInput.trim()) return; + const price = parseFloat(priceInput); + if (isNaN(price)) return; + addEmotionLog({ priceAtEvent: price, emotion, note: noteInput }); + // Clear form after adding + setNoteInput(""); + }; + + // Custom form submit + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedEmotion || !priceInput.trim()) return; + const price = parseFloat(priceInput); + if (isNaN(price)) return; + addEmotionLog({ priceAtEvent: price, emotion: selectedEmotion, note: noteInput }); + setPriceInput(""); + setNoteInput(""); + }; + + // Determine if there's an active trade (entry price available) + const hasActiveTrade = !!activeExecutionId; + + return ( +
+ {/* Header β€” always visible, compact */} +
+

Emotion Logger

+ + {emotionLogs.length} log{emotionLogs.length !== 1 ? "s" : ""} + +
+ + {/* Collapsible content */} +
+ {/* Quick-add button bar */} +
+ {EMOTION_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Inline form */} +
+
+ {/* Emotion selector */} + + + {/* Price input */} + setPriceInput(e.target.value)} + className="w-28 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#3a3b4a]" + /> + + {/* Submit button */} + +
+ + {/* Note textarea */} +