← back to Omega Watches 2
src/components/charts/StockChart.tsx
313 lines
'use client';
import { useState, useMemo } from 'react';
import {
ResponsiveContainer, ComposedChart, Line, Area, XAxis, YAxis,
CartesianGrid, Tooltip, ReferenceLine, Bar
} from 'recharts';
import { formatCurrency, formatDate } from '@/lib/utils';
interface DataPoint {
date: string;
price?: number;
msrp?: number;
volume?: number;
event_type?: string;
source?: string;
sma20?: number;
sma50?: number;
}
interface StockChartProps {
data: DataPoint[];
title?: string;
reference?: string;
onRangeChange?: (range: string) => void;
}
const RANGES = ['1W', '1M', '3M', '6M', '1Y', 'ALL'] as const;
function computeSMA(data: DataPoint[], period: number): (number | undefined)[] {
return data.map((_, i) => {
if (i < period - 1) return undefined;
const slice = data.slice(i - period + 1, i + 1);
const prices = slice.map(d => d.price).filter(Boolean) as number[];
if (prices.length === 0) return undefined;
return prices.reduce((a, b) => a + b, 0) / prices.length;
});
}
function filterByRange(data: DataPoint[], range: string): DataPoint[] {
if (range === 'ALL') return data;
const now = new Date();
const cutoff = new Date();
switch (range) {
case '1W': cutoff.setDate(now.getDate() - 7); break;
case '1M': cutoff.setMonth(now.getMonth() - 1); break;
case '3M': cutoff.setMonth(now.getMonth() - 3); break;
case '6M': cutoff.setMonth(now.getMonth() - 6); break;
case '1Y': cutoff.setFullYear(now.getFullYear() - 1); break;
}
return data.filter(d => new Date(d.date) >= cutoff);
}
const ChartTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-navy-600 bg-navy-900 p-3 shadow-2xl">
<p className="mb-1.5 text-xs font-medium text-navy-300">{formatDate(label)}</p>
{payload.map((entry: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-gray-400">{entry.name}:</span>
<span className="font-mono font-medium" style={{ color: entry.color }}>
{entry.name === 'Volume' ? entry.value : formatCurrency(entry.value)}
</span>
</div>
))}
</div>
);
};
export default function StockChart({ data, title, reference, onRangeChange }: StockChartProps) {
const [range, setRange] = useState<string>('ALL');
const [showMSRP, setShowMSRP] = useState(true);
const [showSMA20, setShowSMA20] = useState(false);
const [showSMA50, setShowSMA50] = useState(false);
const [showVolume, setShowVolume] = useState(true);
const [chartType, setChartType] = useState<'line' | 'area'>('area');
const enrichedData = useMemo(() => {
const filtered = filterByRange(data, range);
const sma20 = computeSMA(filtered, 20);
const sma50 = computeSMA(filtered, 50);
return filtered.map((d, i) => ({
...d,
sma20: sma20[i],
sma50: sma50[i],
volume: d.volume || (d.price ? 1 : 0),
}));
}, [data, range]);
const priceChange = useMemo(() => {
const prices = enrichedData.filter(d => d.price).map(d => d.price!);
if (prices.length < 2) return null;
const first = prices[0];
const last = prices[prices.length - 1];
return { value: last - first, pct: ((last - first) / first) * 100 };
}, [enrichedData]);
const latestMSRP = useMemo(() => {
const msrps = enrichedData.filter(d => d.msrp).map(d => d.msrp!);
return msrps.length > 0 ? msrps[msrps.length - 1] : null;
}, [enrichedData]);
const handleRange = (r: string) => {
setRange(r);
onRangeChange?.(r);
};
if (!data?.length) {
return (
<div className="flex h-96 items-center justify-center rounded-xl border border-navy-700 bg-navy-800/50">
<p className="text-navy-400">Select a watch to view price history</p>
</div>
);
}
return (
<div className="rounded-xl border border-navy-700 bg-navy-800/30">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-navy-700 px-4 py-3">
<div className="flex items-center gap-3">
{title && <h3 className="text-base font-semibold text-white">{title}</h3>}
{reference && <span className="font-mono text-xs text-omega-400">{reference}</span>}
{priceChange && (
<span className={`text-sm font-medium ${priceChange.value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{priceChange.value >= 0 ? '+' : ''}{formatCurrency(priceChange.value)}
<span className="ml-1 text-xs">
({priceChange.pct >= 0 ? '+' : ''}{priceChange.pct.toFixed(1)}%)
</span>
</span>
)}
</div>
<div className="flex items-center gap-1">
{RANGES.map(r => (
<button
key={r}
onClick={() => handleRange(r)}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
range === r
? 'bg-omega-500 text-white'
: 'text-gray-400 hover:bg-navy-700 hover:text-white'
}`}
>
{r}
</button>
))}
</div>
</div>
{/* Toggles */}
<div className="flex flex-wrap items-center gap-3 border-b border-navy-800 px-4 py-2">
<button
onClick={() => setChartType(chartType === 'line' ? 'area' : 'line')}
className="text-xs text-gray-400 hover:text-white"
>
{chartType === 'line' ? '◇ Line' : '◆ Area'}
</button>
<label className="flex items-center gap-1.5 text-xs text-gray-400">
<input type="checkbox" checked={showMSRP} onChange={() => setShowMSRP(!showMSRP)} className="accent-red-500" />
MSRP
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-400">
<input type="checkbox" checked={showSMA20} onChange={() => setShowSMA20(!showSMA20)} className="accent-blue-400" />
SMA(20)
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-400">
<input type="checkbox" checked={showSMA50} onChange={() => setShowSMA50(!showSMA50)} className="accent-purple-400" />
SMA(50)
</label>
<label className="flex items-center gap-1.5 text-xs text-gray-400">
<input type="checkbox" checked={showVolume} onChange={() => setShowVolume(!showVolume)} className="accent-gray-400" />
Volume
</label>
{latestMSRP && (
<span className="ml-auto text-xs text-gray-500">MSRP: {formatCurrency(latestMSRP)}</span>
)}
</div>
{/* Chart */}
<div className="p-2">
<ResponsiveContainer width="100%" height={420}>
<ComposedChart data={enrichedData} margin={{ top: 10, right: 15, bottom: 5, left: 15 }}>
<defs>
<linearGradient id="priceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#c89b3c" stopOpacity={0.3} />
<stop offset="95%" stopColor="#c89b3c" stopOpacity={0} />
</linearGradient>
<linearGradient id="msrpGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.15} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={v => formatDate(v)}
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={{ stroke: '#334155' }}
/>
<YAxis
yAxisId="price"
tickFormatter={v => `$${(v / 1000).toFixed(0)}k`}
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
width={55}
/>
{showVolume && (
<YAxis
yAxisId="volume"
orientation="right"
tickFormatter={v => `${v}`}
stroke="#475569"
fontSize={10}
tickLine={false}
axisLine={false}
width={30}
/>
)}
<Tooltip content={<ChartTooltip />} />
{/* Volume bars */}
{showVolume && (
<Bar
yAxisId="volume"
dataKey="volume"
name="Volume"
fill="#334155"
opacity={0.4}
barSize={4}
/>
)}
{/* MSRP overlay */}
{showMSRP && (
<Area
yAxisId="price"
type="stepAfter"
dataKey="msrp"
name="MSRP"
fill="url(#msrpGrad)"
stroke="#ef4444"
strokeWidth={1.5}
strokeDasharray="6 3"
dot={false}
connectNulls
/>
)}
{/* SMA lines */}
{showSMA20 && (
<Line
yAxisId="price"
type="monotone"
dataKey="sma20"
name="SMA(20)"
stroke="#60a5fa"
strokeWidth={1}
dot={false}
connectNulls
/>
)}
{showSMA50 && (
<Line
yAxisId="price"
type="monotone"
dataKey="sma50"
name="SMA(50)"
stroke="#a78bfa"
strokeWidth={1}
dot={false}
connectNulls
/>
)}
{/* Price line/area */}
{chartType === 'area' ? (
<Area
yAxisId="price"
type="monotone"
dataKey="price"
name="Market Price"
fill="url(#priceGrad)"
stroke="#c89b3c"
strokeWidth={2}
dot={false}
activeDot={{ r: 5, fill: '#c89b3c', stroke: '#fff', strokeWidth: 2 }}
connectNulls
/>
) : (
<Line
yAxisId="price"
type="monotone"
dataKey="price"
name="Market Price"
stroke="#c89b3c"
strokeWidth={2}
dot={false}
activeDot={{ r: 5, fill: '#c89b3c', stroke: '#fff', strokeWidth: 2 }}
connectNulls
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}