feat: add EmotionLogger component with quick-add buttons and stats
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user