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