174 lines
5.0 KiB
TypeScript
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>
|
|
)
|
|
}
|