feat: add EmotionLogger component with quick-add buttons and stats

This commit is contained in:
Investor Flow Build
2026-07-03 22:16:01 -04:00
parent 1007ab4ed5
commit c6027b8a80
+261
View File
@@ -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<string, number>();
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 (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]">
{/* Header — always visible, compact */}
<div className="flex items-center justify-between px-5 py-3 rounded-t-xl">
<h2 className="text-sm font-semibold text-[#e6e7ec]">Emotion Logger</h2>
<span className="text-[10px] text-[#5a5b6a]">
{emotionLogs.length} log{emotionLogs.length !== 1 ? "s" : ""}
</span>
</div>
{/* Collapsible content */}
<div className="px-5 pb-5 space-y-4">
{/* Quick-add button bar */}
<div className="flex flex-wrap gap-1.5">
{EMOTION_OPTIONS.map((opt) => (
<button
key={opt.label}
onClick={() => handleQuickAdd(opt.label)}
title={`Log ${opt.label}`}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
>
<span>{opt.emoji}</span>
<span>{opt.label}</span>
</button>
))}
</div>
{/* Inline form */}
<form onSubmit={handleFormSubmit} className="space-y-2">
<div className="flex gap-2">
{/* Emotion selector */}
<select
value={selectedEmotion}
onChange={(e) => setSelectedEmotion(e.target.value)}
className="flex-1 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#3a3b4a]"
>
<option value="">Select emotion…</option>
{EMOTION_OPTIONS.map((opt) => (
<option key={opt.label} value={opt.label}>
{opt.emoji} {opt.label}
</option>
))}
</select>
{/* Price input */}
<input
type="number"
step="0.01"
placeholder="Price"
value={priceInput}
onChange={(e) => 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 */}
<button
type="submit"
disabled={!selectedEmotion || !priceInput.trim()}
className="rounded-lg bg-[#2a2b3a] text-[#e6e7ec] text-xs px-4 py-2 disabled:opacity-30 hover:bg-[#3a3b4a] transition-colors"
>
Log
</button>
</div>
{/* Note textarea */}
<textarea
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
placeholder="Add a note (optional)"
rows={2}
className="w-full rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 resize-none focus:outline-none focus:border-[#3a3b4a]"
/>
</form>
{/* Log entries */}
{emotionLogs.length > 0 ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin">
{[...emotionLogs].reverse().map((log, idx) => {
// Find the emotion option for styling
const opt = EMOTION_OPTIONS.find((o) => o.label === log.emotion);
// Original index for removal (reverse array maps back)
const originalIdx = emotionLogs.length - 1 - idx;
return (
<div
key={log.id}
className={`flex items-start justify-between gap-2 px-3 py-2 rounded-lg border ${opt?.borderColor ?? "border-[#2a2b3a]"} ${opt?.bgColor ?? "bg-transparent"}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${opt?.color ?? "text-[#5a5b6a]"}`}>
{opt?.emoji} {log.emotion}
</span>
<span className="text-[10px] text-[#5a5b6a]">{formatTime(log.timestamp)}</span>
<span className="text-[10px] text-[#5a5b6a]">@ {formatPrice(log.priceAtEvent)}</span>
</div>
{log.note && (
<p className="text-xs text-[#5a5b6a] mt-0.5 truncate">{log.note}</p>
)}
</div>
<button
onClick={() => removeEmotionLog(originalIdx)}
className="text-[10px] text-[#5a5b6a] hover:text-red-400 transition-colors shrink-0"
title="Remove entry"
>
✕
</button>
</div>
);
})}
</div>
) : (
<p className="text-xs text-[#5a5b6a] italic">No emotion logs yet. Use the buttons above to start tracking.</p>
)}
{/* Stats bar */}
<div className="flex items-center gap-4 px-1 py-2 border-t border-[#2a2b3a]">
<span className="text-[10px] text-[#5a5b6a]">
Today: <span className="text-[#e6e7ec] font-medium">{stats.totalToday}</span>
</span>
{stats.mostCommon && (
<span className="text-[10px] text-[#5a5b6a]">
Most common: <span className="text-[#e6e7ec] font-medium">{stats.mostCommon.label}</span>
<span className="text-[#5a5b6a]"> ({stats.mostCommon.count})</span>
</span>
)}
{stats.avgDeltaFromEntry != null && (
<span className="text-[10px] text-[#5a5b6a]">
Avg delta:{" "}
<span className={`font-medium ${stats.avgDeltaFromEntry >= 0 ? "text-green-400" : "text-red-400"}`}>
{stats.avgDeltaFromEntry >= 0 ? "+" : ""}
{formatPrice(stats.avgDeltaFromEntry)}
</span>
</span>
)}
</div>
</div>
</section>
);
}