SoftTraderFrontend/components/stock-chart/stock-chart.tsx
2026-03-20 11:10:13 -04:00

174 lines
5.0 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import { TIME_RANGE_CONFIGS, buildChartData } from './types'
import type { TimeRange, ChartPoint, TimeRangeConfig } from './types'
import { StockChartCore } from './stock-chart-core'
import { cn } from '@/lib/utils'
export interface StockChartProps {
symbol?: string
range?: TimeRange
endDateTime?: string // ISO 8601 UTC if provided
className?: string
}
function getNowUtcIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
}
function resolveDurationForRequest(cfg: TimeRangeConfig) {
if (cfg.label === '1D') return '4 D'
if (cfg.label === '5D') return '8 D'
if (cfg.label !== 'YTD') return cfg.duration
const now = new Date()
const jan1Utc = new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0))
const daysSince = Math.max(
1,
Math.ceil((now.getTime() - jan1Utc.getTime()) / 86400000)
)
return `${daysSince} D`
}
function buildHistoricalLineUrl(
symbol: string,
cfg: TimeRangeConfig,
endDateTime?: string
) {
const resolvedEndDateTime = endDateTime?.trim() || getNowUtcIso()
const params = new URLSearchParams({
symbol: (symbol ?? '').trim().toUpperCase(),
endDateTime: resolvedEndDateTime,
duration: resolveDurationForRequest(cfg),
barSize: cfg.barSize,
whatToShow: 'Trades',
useRth: 'true',
})
return `https://api.dongfeng-systems.org/api/market/historical-line?${params.toString()}`
}
export function StockChart({
symbol,
range = '1M',
endDateTime,
className,
}: StockChartProps) {
const [data, setData] = useState<ChartPoint[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const safeSymbol = (symbol ?? '').trim().toUpperCase()
const rangeConfig: TimeRangeConfig | undefined = useMemo(
() => TIME_RANGE_CONFIGS.find((r) => r.label === range),
[range]
)
const validPrices = data
.map((d) => d.price)
.filter((price): price is number => price !== null)
const minPrice = validPrices.length ? Math.min(...validPrices) : 0
const maxPrice = validPrices.length ? Math.max(...validPrices) : 0
const fetchData = useCallback(async () => {
if (!safeSymbol) {
setError('Missing symbol')
setData([])
return
}
if (!rangeConfig) {
setError(`Invalid range: ${range}`)
setData([])
return
}
setLoading(true)
setError(null)
try {
const url = buildHistoricalLineUrl(safeSymbol, rangeConfig, endDateTime)
const res = await fetch(url, {
method: 'GET',
cache: 'no-store',
})
if (!res.ok) {
const text = await res.text()
throw new Error(`HTTP ${res.status}: ${text}`)
}
const json = await res.json()
if (!Array.isArray(json)) {
throw new Error('Unexpected response format')
}
const chartData = buildChartData(json, range)
setData(chartData)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load data')
setData([])
} finally {
setLoading(false)
}
}, [safeSymbol, rangeConfig, endDateTime, range])
useEffect(() => {
fetchData()
}, [fetchData])
return (
<div className={cn('bg-card border border-border rounded-xl overflow-hidden', className)}>
<div className="px-2 py-4 min-h-[300px] flex items-center justify-center">
{loading && (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<p className="text-sm">Loading {safeSymbol || 'chart'}...</p>
</div>
)}
{error && !loading && (
<div className="flex flex-col items-center gap-2 text-center px-4">
<p className="text-[oklch(0.55_0.22_25)] text-sm font-medium">Failed to load data</p>
<p className="text-muted-foreground text-xs break-all">{error}</p>
<button
onClick={fetchData}
className="mt-2 text-xs text-foreground border border-border rounded-md px-3 py-1.5 hover:bg-secondary transition-colors"
>
Retry
</button>
</div>
)}
{!loading && !error && data.length > 0 && rangeConfig && (
<div className="w-full">
<StockChartCore data={data} config={rangeConfig} />
</div>
)}
{!loading && !error && data.length === 0 && (
<p className="text-muted-foreground text-sm">No data available for this range.</p>
)}
</div>
{!loading && !error && data.length > 0 && rangeConfig && (
<div className="px-6 pb-4 flex items-center justify-between text-xs text-muted-foreground font-mono border-t border-border pt-3">
<span>{data.length} bars · {rangeConfig.barSize}</span>
<span>
Range: ${minPrice.toFixed(2)} - ${maxPrice.toFixed(2)}
</span>
</div>
)}
</div>
)
}