import { useState } from "react" import type { WidgetDef } from "@/client" import { cn } from "@/lib/utils" import { BarWidget } from "./BarWidget" import { ChartWidget } from "./ChartWidget" import { ClockWidget } from "./ClockWidget" import { ColorWidget } from "./ColorWidget" import { useBoundValue } from "./dataContext" // The grid's own rules live beside the components that use them; CSS is // chunked per entry, so the sheet is pulled in wherever a widget is drawn. import "./dashboard.css" import { ForecastWidget } from "./ForecastWidget" import { IconWidget } from "./IconWidget" import { MediaWidget } from "./MediaWidget" import { usePublish } from "./publish" import { useUi } from "./ui" import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color" import { config, num, rowsOf, text } from "./ui/core/config" /** Widget types that put a value into the graph rather than read one. */ export const INPUT_WIDGETS = new Set([ "button", "switch", "slider", "input", "dropdown", "color", ]) export type WidgetKind = WidgetDef["type"] /** * What a widget can be pointed at, by payload type. * * A switch that reads a float has nothing to show and nothing safe to send, so * the pairing is part of the document rather than a matter of taste. The same * table is enforced on the server (`app/flow/dashboards.py`); a type missing * from it takes anything. */ export const WIDGET_DTYPES: Partial> = { gauge: ["float", "int"], // A chart reading the engine's ring. One that queries binds a `series` // answer and a `record` request instead, checked in `widgetIssue`. chart: ["float", "int"], slider: ["float", "int"], switch: ["bool"], agenda: ["list"], notification: ["record"], bar: ["float", "int"], forecast: ["list"], // Either shape a colour can travel as; its `format` decides which of the two // this widget means, which `widgetIssue` holds the binding to. color: ["list", "str"], // A camera frame, a clip, a segment. What it draws follows the type it is // bound to; a plain artifact is taken as well, since the media type on the // reference is what says what the bytes are. media: ["image", "audio", "video", "artifact"], // An icon maps weather strings, bool hints and numbers alike, and a clock // binds nothing at all, so neither has a row to be held to. } /** Whether a message of this payload type may drive this kind of widget. */ export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) { const allowed = WIDGET_DTYPES[kind] return !allowed || !dtype || allowed.includes(dtype) } export const WIDGET_LABELS: Record = { stat: "Value", gauge: "Gauge", chart: "Chart", markdown: "Text", agenda: "Agenda", notification: "Notification", bar: "Bar", icon: "Icon", forecast: "Forecast", clock: "Clock", media: "Media", button: "Button", switch: "Switch", slider: "Slider", input: "Input", dropdown: "Selector", color: "Colour", } /** Default footprint per type, in grid units. */ export const WIDGET_SIZES: Record = { stat: { w: 3, h: 2 }, gauge: { w: 3, h: 3 }, chart: { w: 6, h: 4 }, markdown: { w: 6, h: 2 }, agenda: { w: 4, h: 4 }, notification: { w: 4, h: 2 }, bar: { w: 4, h: 2 }, icon: { w: 2, h: 2 }, forecast: { w: 6, h: 2 }, clock: { w: 3, h: 2 }, media: { w: 4, h: 4 }, button: { w: 3, h: 2 }, switch: { w: 3, h: 2 }, slider: { w: 4, h: 2 }, input: { w: 4, h: 2 }, dropdown: { w: 4, h: 2 }, color: { w: 4, h: 4 }, } /** One line of a chart, as the document stores it. */ export type Series = { message?: string; dtype?: string; label?: string } export const seriesOf = (widget: WidgetDef): Series[] => (config(widget).series ?? []) as Series[] /** * What is wrong with this widget's wiring, if anything. * * Both halves are checked from the document alone — the picker records the * payload type it bound — so a wall panel can flag a broken tile without * fetching the message catalogue first. */ export function widgetIssue(widget: WidgetDef): string | null { // Neither draws a message: a clock reads the wall, markdown its own text. if (widget.type === "markdown" || widget.type === "clock") return null const cfg = config(widget) if (widget.type === "chart" && cfg.source === "runs") { const runs = (cfg.runs ?? {}) as { metric?: string flow?: string group?: string ids?: string[] } if (!runs.metric) return "This chart names no run metric yet." if (!runs.ids?.length && !runs.group && !runs.flow) { return "Say which runs: a flow, a sweep, or run ids." } return null } if (widget.type === "chart" && cfg.source === "query") { if (!text(cfg.request)) return "This chart does not ask for anything yet." if (!text(cfg.message)) return "This chart has no answer to draw yet." const answer = text(cfg.dtype) if (answer && answer !== "series") { return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.` } const asked = text(cfg.request_dtype) if (asked && asked !== "record") { return `${text(cfg.request)} is a ${asked}; a request is a record.` } return null } if (widget.type === "chart") { const series = seriesOf(widget) if (series.length === 0) return "This chart has no series yet." const wrong = series.find( (entry) => !entry.message || !acceptsDtype("chart", entry.dtype), ) if (wrong) { return wrong.message ? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.` : "One of the series is not bound to a message." } return null } // A bar draws a row per reading, so it is judged row by row — the same way // a chart is judged series by series. if (widget.type === "bar") { const rows = rowsOf(widget) if (rows.length === 0) return "This bar has no rows yet." const wrong = rows.find( (row) => !row.message || !acceptsDtype("bar", row.dtype), ) if (wrong) { return wrong.message ? `${wrong.message} is a ${wrong.dtype}; a bar draws numbers.` : "One of the rows is not bound to a message." } return null } // A media tile playing a camera's own stream binds no message: the browser // fetches it from the source, and the engine is not in the way of it. if (widget.type === "media" && text(cfg.stream_url) && !text(cfg.message)) { return null } const input = INPUT_WIDGETS.has(widget.type) const bound = text(cfg[input ? "target" : "message"]) if (!bound) { return input ? "This control does not publish to a message yet." : "This widget is not bound to a message yet." } const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype) if (!acceptsDtype(widget.type, dtype)) { return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.` } if (widget.type === "color") { const want = COLOR_DTYPES[colorFormatOf(widget)] if (dtype && dtype !== want) { return `${bound} is a ${dtype}; this disc sends ${colorFormatOf(widget)}, which is a ${want}.` } } if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) { return "This icon has nothing mapped yet." } return null } function Unbound() { return

Pick a message.

} // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- function StatWidget({ widget }: WidgetProps) { const { Readout } = useUi() const cfg = config(widget) const message = text(cfg.message) const live = useBoundValue(message || undefined) if (!message) return return ( ) } /** * A dial, drawn as an arc. * * The number is always written out as well: a reading that only exists as an * angle is unreadable to anyone who cannot judge one. */ function GaugeWidget({ widget }: WidgetProps) { const { Gauge } = useUi() const cfg = config(widget) const message = text(cfg.message) const live = useBoundValue(message || undefined) if (!message) return return ( ) } /** * A very small markdown subset, read a line at a time: headings and list items. * Inline spans — bold, code, links — are not parsed and read as written. * * Enough for the labels and notes a dashboard carries, and not worth a parser. */ function MarkdownWidget({ widget }: WidgetProps) { const content = text(config(widget).content) const lines = content.split("\n") return (
{lines.map((line, index) => { const heading = /^(#{1,3})\s+(.*)$/.exec(line) const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "") const bullet = !heading && /^[-*]\s+/.test(line) return (

{bullet ? "• " : ""} {body}

) })}
) } /** One item of an agenda, as the `list` message declares it. */ type AgendaItem = { title: string; ts: number; all_day?: boolean } const DAY_MS = 86_400_000 /** * Which day something falls on, said the way a person would. * * Today and tomorrow by name, the rest of the week by weekday, and anything * further out by date — past a week "Thursday" stops telling you which one. */ function dayLabel(when: Date, now: Date): string { const midnight = new Date(now).setHours(0, 0, 0, 0) const days = Math.floor( (new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS, ) if (days === 0) return "Today" if (days === 1) return "Tomorrow" if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" }) return when.toLocaleDateString() } /** * What is coming up, from a `list` of items the message declares. * * The shape is the widget's contract rather than a path per binding: every * item is `{title, ts}` with an optional `all_day`, so a flow answering with * a calendar decides what an entry is called and this only has to draw it. */ function AgendaWidget({ widget }: WidgetProps) { const cfg = config(widget) const message = text(cfg.message) const live = useBoundValue(message || undefined) if (!message) return const now = new Date() const today = new Date(now).setHours(0, 0, 0, 0) / 1000 const items = (Array.isArray(live?.value) ? live.value : []) .filter( (item): item is AgendaItem => typeof item?.title === "string" && Number.isFinite(item?.ts), ) .filter((item) => item.ts >= today) .sort((a, b) => a.ts - b.ts) .slice(0, num(cfg.count, 5)) if (items.length === 0) { return

Nothing coming up.

} return ( // The frame centres a single reading in its card; a list reads from the // top, so it takes the slack below it.
    {items.map((item, index) => { const when = new Date(item.ts * 1000) return (
  • {dayLabel(when, now)} {item.all_day ? "" : ` ${when.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", })}`} {item.title}
  • ) })}
) } /** * The last thing worth saying, held until something replaces it. * * No state of its own: the live store already keeps the latest value of a * message, so what was published stays on the panel until the next one lands. */ function NotificationWidget({ widget }: WidgetProps) { const cfg = config(widget) const message = text(cfg.message) const live = useBoundValue(message || undefined) if (!message) return const record = (live?.value ?? null) as Record | null const title = text(record?.title) const body = text(record?.body) if (!title && !body) { return

Nothing to report.

} const failed = record?.severity === "error" return (
{title ? (

{/* Never colour alone: a notice that went wrong says so in a word as well as in red. */} {failed ? Error: : null} {failed ? : null} {title}

) : null} {body ?

{body}

: null}
) } // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- function ButtonWidget({ widget, dashboard }: WidgetProps) { const { Button } = useUi() const cfg = config(widget) const { target, send, pending, pulse, locked } = usePublish(widget, dashboard) if (!target) return // Nothing to hold: a button carries no reading, so the pulse and a refusal // are the whole of its feedback. return ( <> {pulse} ) } /** * A bool, published and read back — a latch either way it is drawn. * * `style: "button"` is a control that stays in rather than a track; both name * the state in words, because a fill alone does not say what it means. */ function SwitchWidget({ widget, dashboard }: WidgetProps) { const { Button, Switch } = useUi() const cfg = config(widget) const { target, value, send, pulse, locked } = usePublish(widget, dashboard) if (!target) return const on = value === true return cfg.style === "button" ? ( <> {pulse} ) : (
{pulse} {on ? "On" : "Off"} send(checked)} />
) } function SliderWidget({ widget, dashboard }: WidgetProps) { const { Readout, Slider } = useUi() const cfg = config(widget) const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const min = num(cfg.min, 0) if (!target) return const unit = cfg.unit ? text(cfg.unit) : undefined return ( // Value beside the track rather than above it, the way a bar row reads — // one row instead of two, and the tile keeps the height for the control.
{pulse}
{/* Held to the control's own height, so the value sits on the track's midline whether or not there is a row of ticks under it. */}
) } function InputWidget({ widget, dashboard }: WidgetProps) { const { Input } = useUi() const cfg = config(widget) const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const [draft, setDraft] = useState(null) if (!target) return const asNumber = cfg.dtype === "float" || cfg.dtype === "int" return ( <> {pulse} { if (draft === null) return send(asNumber ? Number(draft) || 0 : draft) setDraft(null) }} /> ) } /** * One of N, published and read back. * * `style: "segmented"` shows every choice at once with the active one held — * the same exclusive group, drawn for a panel that is looked at across a room. */ function DropdownWidget({ widget, dashboard }: WidgetProps) { const { Segmented, Select } = useUi() const cfg = config(widget) const { target, value, send, pulse, locked } = usePublish(widget, dashboard) const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] if (!target) return if (cfg.style === "segmented") { return ( <> {pulse} [text(option.value), option.label ?? text(option.value)] as const, )} label={widget.title || target} disabled={locked} onChange={(picked) => send(options.find((o) => text(o.value) === picked)?.value ?? picked) } /> ) } return ( <> {pulse}