import { useState } from "react" import type { WidgetDef } from "@/client" import { useLiveValue } from "@/components/Flow/liveStore" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { BarWidget, segmentsOf } from "./BarWidget" import { ChartWidget } from "./ChartWidget" import { ClockWidget } from "./ClockWidget" import { ForecastWidget } from "./ForecastWidget" import { IconWidget } from "./IconWidget" import { usePublishMessage } from "./queries" /** Widget types that put a value into the graph rather than read one. */ export const INPUT_WIDGETS = new Set([ "button", "switch", "slider", "input", "dropdown", ]) 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"], // 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", button: "Button", switch: "Switch", slider: "Slider", input: "Input", dropdown: "Dropdown", } /** 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 }, 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 }, } function config(widget: WidgetDef): Record { return (widget.config ?? {}) as Record } function text(value: unknown, fallback = ""): string { return value === null || value === undefined ? fallback : String(value) } function num(value: unknown, fallback: number): number { const parsed = Number(value) return Number.isFinite(parsed) ? parsed : fallback } /** Formats a reading the way a panel across the room should read it. */ function format(value: unknown, precision: number | null): string { if (value === null || value === undefined) return "—" if (typeof value === "boolean") return value ? "On" : "Off" if (typeof value === "number") { return precision === null ? String(value) : value.toFixed(precision) } if (typeof value === "object") return JSON.stringify(value) return String(value) } /** 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 === "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 } 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.` } // Only a bar nests further readings, and an unrecorded type binds anything. // Read through `segmentsOf` so a stacked bar is judged segment by segment // rather than only in the one-reading shape it used to carry. for (const segment of segmentsOf(widget)) { if (!acceptsDtype(widget.type, segment.dtype || undefined)) { return `${segment.message} is a ${segment.dtype}; a bar nests numbers.` } } if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) { return "This icon has nothing mapped yet." } return null } /** * The frame every widget sits in. * * A card rather than floating chrome: a dashboard is content, and the panels * that float are the ones that sit over something. */ export function WidgetFrame({ title, children, actions, issue, grip, className, onClick, }: { title?: string children: React.ReactNode actions?: React.ReactNode /** Mis-wired: the same red dot and tooltip a failing node carries. */ issue?: string | null /** Make the header the handle the editor drags the widget by. */ grip?: boolean className?: string onClick?: React.MouseEventHandler }) { return ( // A card is not a control: the click only picks it in edit mode, and every // interactive element inside keeps its own role and keyboard handling. // biome-ignore lint/a11y/useKeyWithClickEvents: see above. // biome-ignore lint/a11y/noStaticElementInteractions: see above.
{title || actions || issue || grip ? (
{title ? ( {title} ) : ( )} {issue ? ( {issue} ) : null} {actions}
) : null} {/* A scroller, not a clip: the header is an earlier sibling, so anything taller than the card would otherwise paint over the title instead of being reachable. Centring has to be `safe` — plain `center` overflows both edges at once and puts the top of a long body out of reach. */}
{children}
) } function Unbound() { return

Pick a message.

} // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- function StatWidget({ widget }: WidgetProps) { const cfg = config(widget) const message = text(cfg.message) const live = useLiveValue(message || undefined) if (!message) return const precision = cfg.precision === undefined ? null : num(cfg.precision, 1) return (
{format(live?.value, precision)} {cfg.unit ? ( {text(cfg.unit)} ) : null}
) } /** * 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 cfg = config(widget) const message = text(cfg.message) const live = useLiveValue(message || undefined) if (!message) return const min = num(cfg.min, 0) const max = num(cfg.max, 100) const value = typeof live?.value === "number" ? live.value : null const fraction = value === null ? 0 : Math.min(1, Math.max(0, (value - min) / (max - min || 1))) // A 240° arc, the shape a dial is expected to have. const radius = 42 const sweep = 240 const start = 150 const point = (angle: number) => { const radians = (angle * Math.PI) / 180 return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)] } const arc = (from: number, to: number) => { const [x1, y1] = point(from) const [x2, y2] = point(to) const large = Math.abs(to - from) > 180 ? 1 : 0 return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}` } return (
{/* The same full arc as the track, revealed by the dash: `d` is not transitionable, so a reading that re-paths the arc can only jump. `pathLength` normalises it to 1, which makes the offset the fraction itself and saves measuring the geometry. */} {format( value, cfg.precision === undefined ? 1 : num(cfg.precision, 1), )} {cfg.unit ? text(cfg.unit) : ""}
) } /** * 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 = useLiveValue(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 = useLiveValue(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.

} return (
{title ? (

{title}

) : null} {body ?

{body}

: null}
) } // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- /** Publishing, with the value shown as sent until the engine confirms it. */ function usePublish(widget: WidgetDef, dashboard: string) { const cfg = config(widget) const target = text(cfg.target) const publish = usePublishMessage() const live = useLiveValue(target || undefined) return { target, live, send: (value: unknown) => { if (!target) return publish.mutate({ name: target, value, dashboard, widget: widget.id, label: widget.title || widget.id, kind: widget.type, }) }, pending: publish.isPending, } } function ButtonWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) const { target, send, pending } = usePublish(widget, dashboard) if (!target) return return ( ) } /** * 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 cfg = config(widget) const { target, live, send } = usePublish(widget, dashboard) if (!target) return const on = live?.value === true return cfg.style === "button" ? ( ) : (
{on ? "On" : "Off"} send(checked)} />
) } function SliderWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) const { target, live, send } = usePublish(widget, dashboard) const min = num(cfg.min, 0) const max = num(cfg.max, 100) const step = num(cfg.step, 1) // While dragging, the handle follows the finger rather than the engine. const [dragging, setDragging] = useState(null) if (!target) return const current = dragging ?? (typeof live?.value === "number" ? live.value : min) // A 20–22 °C setpoint at 0.1 is unusable without marks to aim at. Past // fifty of them the ticks are a smear, so the browser gets none. const steps = step > 0 ? (max - min) / step : 0 const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0 const ticksId = `ticks-${widget.id}` return (
{current} {cfg.unit ? ( {text(cfg.unit)} ) : null}
{ticks ? ( {Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => ( ) : null} setDragging(Number(event.target.value))} // Only the release publishes: dragging would otherwise send a value // per pixel and flood whatever is listening. onPointerUp={() => { if (dragging !== null) send(dragging) setDragging(null) }} onKeyUp={() => { if (dragging !== null) send(dragging) setDragging(null) }} />
) } function InputWidget({ widget, dashboard }: WidgetProps) { const cfg = config(widget) const { target, live, send } = usePublish(widget, dashboard) const [draft, setDraft] = useState(null) if (!target) return const asNumber = cfg.dtype === "float" || cfg.dtype === "int" const commit = () => { if (draft === null) return send(asNumber ? Number(draft) || 0 : draft) setDraft(null) } return ( setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === "Enter") commit() }} /> ) } /** * 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 cfg = config(widget) const { target, live, send } = usePublish(widget, dashboard) const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] if (!target) return if (cfg.style === "segmented") { const chosen = options.findIndex( (option) => text(option.value) === text(live?.value), ) return ( // The one segmented shape: a single border pill, no dividers, // transparent segments, bg-accent on the selected one — held by a thumb // that slides rather than a fill that jumps from cell to cell. A // `fieldset` carries `min-inline-size: min-content` from the UA sheet, // which `w-full` does not override.
{widget.title || target} {chosen >= 0 ? ( // Equal tracks and no gap, so a segment is exactly its share of the // padded box and the thumb needs no measuring. ) : null} {options.map((option, index) => ( ))}
) } return (
) } /** Radix hands back a string; the message wants whatever was configured. */ function asOriginal(selected: string, options: { value?: unknown }[]): unknown { const match = options.find((option) => text(option.value) === selected) return match ? match.value : selected } // --------------------------------------------------------------------------- export type WidgetProps = { widget: WidgetDef; dashboard: string } const RENDERERS: Partial< Record React.ReactNode> > = { stat: StatWidget, gauge: GaugeWidget, chart: ChartWidget, markdown: MarkdownWidget, agenda: AgendaWidget, notification: NotificationWidget, bar: BarWidget, icon: IconWidget, forecast: ForecastWidget, clock: ClockWidget, button: ButtonWidget, switch: SwitchWidget, slider: SliderWidget, input: InputWidget, dropdown: DropdownWidget, } export function WidgetBody({ widget, dashboard }: WidgetProps) { const Renderer = RENDERERS[widget.type] if (!Renderer) { return (

{WIDGET_LABELS[widget.type]} widgets are not drawn yet.

) } return }