Jaspers Hub

biseroff / charts

pluginReviewed★ 0↓ 0

Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.

3.1 KB
1import { useData } from '@jaspers-ai/sdk'
2import { useEffect, useMemo, useRef, useState } from 'react'
3import { mergeBars, requestsFor, rowsFromText, toBars, type Bar, type HistoryArgs, type Interval } from './plan'
4
5/** What one symbol's calls have answered so far. */
6export interface FeedResult {
7 bars: Bar[]
8 error: string | null
9 loading: boolean
10 /** A short state for the chip: queued, rate-limited, retrying in 20s, failed, refresh failed. */
11 note?: string
12 /** The message behind the note, for the chip's tooltip. */
13 detail?: string
14}
15
16const EMPTY: FeedResult = { bars: [], error: null, loading: true }
17
18/** Runs every call for one symbol at one interval and reports the merged bars under "interval:symbol"; draws nothing. */
19export function SymbolFeed(props: {
20 symbol: string
21 interval: Interval
22 tick: number
23 onResult: (symbol: string, result: FeedResult) => void
24}) {
25 const { symbol, interval, tick, onResult } = props
26 // Dates are worked out again on each refresh, so a new day moves the window.
27 const requests = useMemo(() => requestsFor(symbol, interval, new Date()), [symbol, interval, tick])
28 const [parts, setParts] = useState<FeedResult[]>([])
29 useEffect(() => setParts([]), [symbol, interval])
30
31 useEffect(() => {
32 const done = requests.map((_, i) => parts[i] ?? EMPTY)
33 const bars = mergeBars(done.map((p) => p.bars))
34 const loading = done.some((p) => p.loading)
35 // Older 1m chunks Yahoo refuses are dropped quietly while any call has bars.
36 const error = bars.length === 0 && !loading ? (done.find((p) => p.error)?.error ?? 'No data') : null
37 onResult(`yahoo:${interval}:${symbol}`, { bars, error, loading })
38 }, [parts, requests, symbol, interval, onResult])
39
40 return (
41 <>
42 {requests.map((args, i) => (
43 <Chunk
44 key={`${interval}-${i}`}
45 args={args}
46 interval={interval}
47 tick={tick}
48 onDone={(result) =>
49 setParts((prev) => {
50 const next = prev.slice()
51 next[i] = result
52 return next
53 })
54 }
55 />
56 ))}
57 </>
58 )
59}
60
61/** One history call. Runs again when its arguments change, and on each refresh tick. */
62function Chunk(props: { args: HistoryArgs; interval: Interval; tick: number; onDone: (r: FeedResult) => void }) {
63 const { args, interval, tick, onDone } = props
64 const { data, text, error, loading, refetch } = useData('charts/history', { ...args })
65 const lastTick = useRef(tick)
66 const onDoneRef = useRef(onDone)
67 onDoneRef.current = onDone
68
69 useEffect(() => {
70 if (lastTick.current === tick) return
71 lastTick.current = tick
72 refetch?.()
73 }, [tick, refetch])
74
75 useEffect(() => {
76 const rows = Array.isArray(data) && data.length > 0 ? (data as unknown[]) : rowsFromText(text)
77 const bars = toBars(rows, interval)
78 const pending = !error && (loading || (data === undefined && !text))
79 const message = error
80 ? String(error).split('\n')[0]
81 : bars.length === 0 && !pending
82 ? (text ?? 'No data').split('\n')[0].slice(0, 160)
83 : null
84 onDoneRef.current({ bars, error: message, loading: pending && bars.length === 0 })
85 }, [data, text, error, loading, interval])
86
87 return null
88}