Spaces:
Running
Running
| 'use client'; | |
| /** | |
| * LineChart.tsx β Real historical price chart using lightweight-charts v5 | |
| * | |
| * Fetches from /finance/historical/{symbol} (yfinance β Finnhub β FMP fallback chain) | |
| * Supports timeframe switching: 1W | 1M | 3M | 6M | 1Y | |
| * Gracefully degrades to a "no data" state if all sources fail. | |
| */ | |
| import { useEffect, useRef, useState, useCallback } from 'react'; | |
| import { getApiBaseUrl } from '@/lib/api'; | |
| interface HistoricalPoint { | |
| date: string; | |
| open: number; | |
| high: number; | |
| low: number; | |
| close: number; | |
| volume: number; | |
| } | |
| interface Props { | |
| symbol: string; | |
| companyName?: string; | |
| isPositive?: boolean; | |
| height?: number; | |
| } | |
| type Timeframe = '1W' | '1M' | '3M' | '6M' | '1Y'; | |
| const TIMEFRAME_DAYS: Record<Timeframe, number> = { | |
| '1W': 7, | |
| '1M': 30, | |
| '3M': 90, | |
| '6M': 180, | |
| '1Y': 365, | |
| }; | |
| export default function LineChart({ symbol, companyName, isPositive = true, height = 280 }: Props) { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const chartRef = useRef<unknown>(null); | |
| const seriesRef = useRef<unknown>(null); | |
| const [allData, setAllData] = useState<HistoricalPoint[]>([]); | |
| const [timeframe, setTimeframe] = useState<Timeframe>('3M'); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [currentPrice, setCurrentPrice] = useState<number | null>(null); | |
| const [priceChange, setPriceChange] = useState<{ abs: number; pct: number } | null>(null); | |
| // ββ Fetch historical data ββββββββββββββββββββββββββββββββββββββββββββββ | |
| const fetchData = useCallback(async () => { | |
| if (!symbol) return; | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const apiBase = getApiBaseUrl(); | |
| const ctrl = new AbortController(); | |
| const timer = setTimeout(() => ctrl.abort(), 25_000); | |
| const res = await fetch( | |
| `${apiBase}/finance/historical/${symbol.toUpperCase()}?outputsize=full`, | |
| { signal: ctrl.signal } | |
| ); | |
| clearTimeout(timer); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const json = await res.json(); | |
| const data: HistoricalPoint[] = json.data ?? []; | |
| setAllData(data); | |
| if (data.length > 0) { | |
| const last = data[data.length - 1]; | |
| const first = data[data.length - 2] ?? data[0]; | |
| setCurrentPrice(last.close); | |
| setPriceChange({ | |
| abs: last.close - first.close, | |
| pct: ((last.close - first.close) / first.close) * 100, | |
| }); | |
| } | |
| } catch (e: unknown) { | |
| const msg = (e as Error).name === 'AbortError' | |
| ? 'Request timed out' | |
| : 'Failed to load chart data'; | |
| setError(msg); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [symbol]); | |
| useEffect(() => { fetchData(); }, [fetchData]); | |
| // ββ Filter by timeframe ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const filteredData = useCallback((): HistoricalPoint[] => { | |
| if (!allData.length) return []; | |
| const days = TIMEFRAME_DAYS[timeframe]; | |
| const cutoff = new Date(); | |
| cutoff.setDate(cutoff.getDate() - days); | |
| const cutoffStr = cutoff.toISOString().split('T')[0]; | |
| return allData.filter(d => d.date >= cutoffStr); | |
| }, [allData, timeframe]); | |
| // ββ Build/update chart ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| useEffect(() => { | |
| if (loading || !containerRef.current) return; | |
| const data = filteredData(); | |
| // Lazy-load lightweight-charts | |
| import('lightweight-charts').then(({ AreaSeries, LineSeries, LineStyle, createChart }) => { | |
| // Destroy previous chart | |
| if (chartRef.current) { | |
| (chartRef.current as { remove(): void }).remove(); | |
| chartRef.current = null; | |
| seriesRef.current = null; | |
| } | |
| if (!containerRef.current || data.length === 0) return; | |
| const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| const chart = createChart(containerRef.current, { | |
| width: containerRef.current.clientWidth, | |
| height, | |
| layout: { | |
| background: { color: 'transparent' }, | |
| textColor: isDark ? '#9ca3af' : '#6b7280', | |
| fontSize: 11, | |
| }, | |
| grid: { | |
| vertLines: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }, | |
| horzLines: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }, | |
| }, | |
| crosshair: { mode: 1 }, | |
| rightPriceScale: { | |
| borderColor: 'transparent', | |
| scaleMargins: { top: 0.1, bottom: 0.1 }, | |
| }, | |
| timeScale: { | |
| borderColor: 'transparent', | |
| timeVisible: true, | |
| secondsVisible: false, | |
| }, | |
| handleScroll: true, | |
| handleScale: true, | |
| }); | |
| const color = isPositive ? '#10b981' : '#ef4444'; | |
| const series = chart.addSeries(LineSeries, { | |
| color, | |
| lineWidth: 2, | |
| crosshairMarkerVisible: true, | |
| crosshairMarkerRadius: 4, | |
| lastValueVisible: true, | |
| priceLineVisible: true, | |
| priceLineStyle: LineStyle.Dashed, | |
| priceLineWidth: 1, | |
| priceLineColor: color, | |
| }); | |
| const chartData = data.map(d => ({ time: d.date as string, value: d.close })); | |
| series.setData(chartData); | |
| // Area fill | |
| const areaSeries = chart.addSeries(AreaSeries, { | |
| topColor: isPositive ? 'rgba(16,185,129,0.12)' : 'rgba(239,68,68,0.12)', | |
| bottomColor: 'rgba(0,0,0,0)', | |
| lineColor: 'transparent', | |
| lineWidth: 1, | |
| }); | |
| areaSeries.setData(chartData); | |
| chart.timeScale().fitContent(); | |
| chartRef.current = chart; | |
| seriesRef.current = series; | |
| // Resize observer | |
| const ro = new ResizeObserver(() => { | |
| if (containerRef.current) { | |
| chart.applyOptions({ width: containerRef.current.clientWidth }); | |
| } | |
| }); | |
| if (containerRef.current) ro.observe(containerRef.current); | |
| }).catch(() => { | |
| setError('Chart library failed to load'); | |
| }); | |
| return () => { | |
| if (chartRef.current) { | |
| (chartRef.current as { remove(): void }).remove(); | |
| chartRef.current = null; | |
| seriesRef.current = null; | |
| } | |
| }; | |
| }, [loading, timeframe, allData, isPositive, height, filteredData]); | |
| // ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const positiveClass = isPositive ? 'text-emerald-400' : 'text-red-400'; | |
| return ( | |
| <div className="relative w-full"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-3 px-1"> | |
| <div> | |
| {currentPrice !== null && ( | |
| <div className="flex items-baseline gap-2"> | |
| <span className="text-xl font-semibold tabular-nums"> | |
| ${currentPrice.toFixed(2)} | |
| </span> | |
| {priceChange && ( | |
| <span className={`text-sm ${positiveClass}`}> | |
| {priceChange.abs >= 0 ? '+' : ''}{priceChange.abs.toFixed(2)} | |
| {' '}({priceChange.pct >= 0 ? '+' : ''}{priceChange.pct.toFixed(2)}%) | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Timeframe buttons */} | |
| <div className="flex gap-1"> | |
| {(Object.keys(TIMEFRAME_DAYS) as Timeframe[]).map(tf => ( | |
| <button | |
| key={tf} | |
| onClick={() => setTimeframe(tf)} | |
| className={`px-2 py-0.5 rounded text-xs font-medium transition-colors | |
| ${timeframe === tf | |
| ? 'bg-white/10 text-white' | |
| : 'text-gray-500 hover:text-gray-300' | |
| }`} | |
| > | |
| {tf} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Chart area */} | |
| <div style={{ height, position: 'relative' }}> | |
| {loading && ( | |
| <div className="absolute inset-0 flex items-center justify-center"> | |
| <div className="flex gap-1"> | |
| {[0,1,2].map(i => ( | |
| <div | |
| key={i} | |
| className="w-1.5 h-1.5 rounded-full bg-white/30 animate-pulse" | |
| style={{ animationDelay: `${i * 150}ms` }} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {!loading && error && ( | |
| <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-500"> | |
| <span className="text-sm">{error}</span> | |
| <button | |
| onClick={fetchData} | |
| className="text-xs text-blue-400 hover:text-blue-300 underline" | |
| > | |
| Retry | |
| </button> | |
| </div> | |
| )} | |
| {!loading && !error && allData.length === 0 && ( | |
| <div className="absolute inset-0 flex items-center justify-center text-gray-500 text-sm"> | |
| No chart data available for {symbol} | |
| </div> | |
| )} | |
| <div ref={containerRef} className="w-full" style={{ height }} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |