import { useEffect, useState, type ReactElement, type ReactNode } from 'react'
import { useBridge, useData, usePublish, type PanelRef } from '@jaspers-ai/sdk'
import { display as d } from './format'
import { cleanError, position, tone, type Run } from './views'
// What the five views share: the panel's state as one value, writes to it, the bar and its symbol
// field, a section that shows its run's state before its content, figures with their sign, a range
// bar, and the minute timer the quote views ask again on.
/** The panel's whole state, a round trip after the frame mounts; undefined until then. */
export function usePanelStateValue(panel: PanelRef): unknown {
return useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`)
}
/** Writes keys of the panel's state in order; main checks each write against the view's schema. */
export function useStateWriter(panel: PanelRef): (changes: [string, unknown][]) => void {
const bridge = useBridge()
return (changes) => {
void (async () => {
for (const [key, value] of changes) await bridge.setState(panel, [key], value)
})()
}
}
/** Runs `refetch` every `ms` while the view is mounted. */
export function useEvery(refetch: () => void, ms: number): void {
useEffect(() => {
const timer = setInterval(refetch, ms)
return () => clearInterval(timer)
}, [refetch, ms])
}
export function Loading(): ReactElement {
return (
Loading…
)
}
export function Bar({ children }: { children: ReactNode }): ReactElement {
return
{children}
}
/**
* The symbol field. Enter hands the typed symbol over; a symbol the orchestrator sets shows here.
* Enter is read off the key: the frame is sandboxed without allow-forms, so a form never submits.
*/
export function SymbolField({
symbol,
onSymbol,
placeholder = 'Symbol',
clear = false,
}: {
symbol: string
onSymbol: (symbol: string) => void
placeholder?: string
/** Empty the field after Enter, for a field that adds rather than sets. */
clear?: boolean
}): ReactElement {
const [draft, setDraft] = useState(symbol)
useEffect(() => setDraft(symbol), [symbol])
return (
setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
event.preventDefault()
const next = draft.trim().toUpperCase()
if (next) onSymbol(next)
if (clear) setDraft('')
}}
/>
)
}
/** A view with nothing to show yet says so, and publishes that. */
export function Empty({ panel, output, children }: { panel: PanelRef; output: Record; children: ReactNode }): ReactElement {
usePublish(panel, output)
return
{children}
}
/** A titled block: its run's error (Yahoo's own words) or loading line first, then its content. */
export function Section({
title,
run,
aside,
empty,
children,
}: {
title: string
run?: Pick
aside?: ReactNode
empty?: boolean
children?: ReactNode
}): ReactElement {
return (
: children}
)
}
/** A signed figure, green up and red down. */
export function Signed({ value, percent = false, hint }: { value: number | null | undefined; percent?: boolean; hint?: number | null }): ReactElement {
const shown = percent ? d.signedPercent(value) : d.signed(value, typeof hint === 'number' ? hint : 2)
return {shown}
}
/** A track from low to high with a mark where the value is, and the ends written under it. */
export function RangeBar({
label,
low,
high,
value,
mark,
hint,
}: {
label: string
low: number | null
high: number | null
value: number | null
/** A second, hollow mark, like the mean target. */
mark?: { value: number | null; title: string }
hint?: number | null
}): ReactElement | null {
const at = position(low, high, value)
if (at === null) return null
const second = mark ? position(low, high, mark.value) : null
return (
{label}
{second !== null && }
{d.price(low, hint)}{d.price(high, hint)}
)
}
/** Market state in words. */
export function marketState(state: string | null): string {
switch (state) {
case 'PRE':
return 'Pre-market'
case 'REGULAR':
return 'Market open'
case 'POST':
return 'After hours'
case null:
return ''
default:
return 'Market closed'
}
}