Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

5.5 KB
1import { useEffect, useState, type ReactElement, type ReactNode } from 'react'
2import { useBridge, useData, usePublish, type PanelRef } from '@jaspers-ai/sdk'
3import { display as d } from './format'
4import { cleanError, position, tone, type Run } from './views'
5
6// What the five views share: the panel's state as one value, writes to it, the bar and its symbol
7// field, a section that shows its run's state before its content, figures with their sign, a range
8// bar, and the minute timer the quote views ask again on.
9
10/** The panel's whole state, a round trip after the frame mounts; undefined until then. */
11export function usePanelStateValue(panel: PanelRef): unknown {
12 return useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`)
13}
14
15/** Writes keys of the panel's state in order; main checks each write against the view's schema. */
16export function useStateWriter(panel: PanelRef): (changes: [string, unknown][]) => void {
17 const bridge = useBridge()
18 return (changes) => {
19 void (async () => {
20 for (const [key, value] of changes) await bridge.setState(panel, [key], value)
21 })()
22 }
23}
24
25/** Runs `refetch` every `ms` while the view is mounted. */
26export function useEvery(refetch: () => void, ms: number): void {
27 useEffect(() => {
28 const timer = setInterval(refetch, ms)
29 return () => clearInterval(timer)
30 }, [refetch, ms])
31}
32
33export function Loading(): ReactElement {
34 return (
35 <div className="yf-root">
36 <div className="yf-bar">
37 <span className="yf-muted">Loading…</span>
38 </div>
39 </div>
40 )
41}
42
43export function Bar({ children }: { children: ReactNode }): ReactElement {
44 return <div className="yf-bar">{children}</div>
45}
46
47/**
48 * The symbol field. Enter hands the typed symbol over; a symbol the orchestrator sets shows here.
49 * Enter is read off the key: the frame is sandboxed without allow-forms, so a form never submits.
50 */
51export function SymbolField({
52 symbol,
53 onSymbol,
54 placeholder = 'Symbol',
55 clear = false,
56}: {
57 symbol: string
58 onSymbol: (symbol: string) => void
59 placeholder?: string
60 /** Empty the field after Enter, for a field that adds rather than sets. */
61 clear?: boolean
62}): ReactElement {
63 const [draft, setDraft] = useState(symbol)
64 useEffect(() => setDraft(symbol), [symbol])
65 return (
66 <input
67 className="yf-input yf-symbol"
68 name="symbol"
69 value={draft}
70 placeholder={placeholder}
71 spellCheck={false}
72 autoComplete="off"
73 onChange={(event) => setDraft(event.target.value)}
74 onKeyDown={(event) => {
75 if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
76 event.preventDefault()
77 const next = draft.trim().toUpperCase()
78 if (next) onSymbol(next)
79 if (clear) setDraft('')
80 }}
81 />
82 )
83}
84
85/** A view with nothing to show yet says so, and publishes that. */
86export function Empty({ panel, output, children }: { panel: PanelRef; output: Record<string, unknown>; children: ReactNode }): ReactElement {
87 usePublish(panel, output)
88 return <p className="yf-empty">{children}</p>
89}
90
91/** A titled block: its run's error (Yahoo's own words) or loading line first, then its content. */
92export function Section({
93 title,
94 run,
95 aside,
96 empty,
97 children,
98}: {
99 title: string
100 run?: Pick<Run, 'loading' | 'error'>
101 aside?: ReactNode
102 empty?: boolean
103 children?: ReactNode
104}): ReactElement {
105 return (
106 <section className="yf-section">
107 <h2 className="yf-h">
108 <span>{title}</span>
109 {aside && <span className="yf-muted yf-aside">{aside}</span>}
110 {run?.loading && <span className="yf-muted yf-aside">loading…</span>}
111 </h2>
112 {run?.error ? <p className="yf-error">{cleanError(run.error)}</p> : empty && !run?.loading ? <p className="yf-muted">Nothing.</p> : children}
113 </section>
114 )
115}
116
117/** A signed figure, green up and red down. */
118export function Signed({ value, percent = false, hint }: { value: number | null | undefined; percent?: boolean; hint?: number | null }): ReactElement {
119 const shown = percent ? d.signedPercent(value) : d.signed(value, typeof hint === 'number' ? hint : 2)
120 return <span className={`yf-num yf-${tone(value) || 'flat'}`}>{shown}</span>
121}
122
123/** A track from low to high with a mark where the value is, and the ends written under it. */
124export function RangeBar({
125 label,
126 low,
127 high,
128 value,
129 mark,
130 hint,
131}: {
132 label: string
133 low: number | null
134 high: number | null
135 value: number | null
136 /** A second, hollow mark, like the mean target. */
137 mark?: { value: number | null; title: string }
138 hint?: number | null
139}): ReactElement | null {
140 const at = position(low, high, value)
141 if (at === null) return null
142 const second = mark ? position(low, high, mark.value) : null
143 return (
144 <div className="yf-range">
145 <div className="yf-range-label yf-muted">{label}</div>
146 <div className="yf-track">
147 {second !== null && <span className="yf-mark yf-mark-hollow" style={{ left: `${second * 100}%` }} title={mark!.title} />}
148 <span className="yf-mark" style={{ left: `${at * 100}%` }} title={d.price(value, hint)} />
149 </div>
150 <div className="yf-range-ends yf-num yf-muted">
151 <span>{d.price(low, hint)}</span>
152 <span>{d.price(high, hint)}</span>
153 </div>
154 </div>
155 )
156}
157
158/** Market state in words. */
159export function marketState(state: string | null): string {
160 switch (state) {
161 case 'PRE':
162 return 'Pre-market'
163 case 'REGULAR':
164 return 'Market open'
165 case 'POST':
166 return 'After hours'
167 case null:
168 return ''
169 default:
170 return 'Market closed'
171 }
172}