Bump dashboards: bar, icon, forecast and clock widget types

Plumbing only: the widget-type literal and its dtype table on both sides,
the regenerated client, a curated lucide map and four stubs the renderers
are wired to. Also a latching switch and a segmented dropdown, both a
`style` on the control that already publishes and reads back, plus the
option editor a dropdown never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 08:31:36 +02:00
co-authored by Claude Opus 5
parent 6be718f672
commit dbaa3518b9
11 changed files with 389 additions and 22 deletions
+86 -4
View File
@@ -19,7 +19,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { BarWidget } 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. */
@@ -50,6 +54,10 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
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. */
@@ -65,6 +73,10 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
bar: "Bar",
icon: "Icon",
forecast: "Forecast",
clock: "Clock",
button: "Button",
switch: "Switch",
slider: "Slider",
@@ -80,6 +92,10 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
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 },
@@ -125,7 +141,8 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return 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") {
@@ -167,6 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
// Only a bar nests a second reading, and an unrecorded type binds anything.
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet."
}
return null
}
@@ -550,14 +574,33 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
)
}
/**
* 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 <Unbound />
return (
const on = live?.value === true
return cfg.style === "button" ? (
<Button
variant={on ? "default" : "secondary"}
className="w-full"
aria-pressed={on}
aria-label={widget.title || target}
onClick={() => send(!on)}
>
{on ? "On" : "Off"}
</Button>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
<span className="text-sm">{on ? "On" : "Off"}</span>
<Switch
checked={live?.value === true}
checked={on}
aria-label={widget.title || target}
onCheckedChange={(checked) => send(checked)}
/>
@@ -653,12 +696,47 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
)
}
/**
* 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 <Unbound />
if (cfg.style === "segmented") {
return (
// The one segmented shape: a single border pill, no dividers,
// transparent segments, bg-accent on the selected one.
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
<legend className="sr-only">{widget.title || target}</legend>
{options.map((option) => {
const selected = text(option.value) === text(live?.value)
return (
<button
key={text(option.value)}
type="button"
aria-pressed={selected}
onClick={() => send(option.value)}
className={cn(
"h-11 min-w-0 flex-1 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
selected
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
)
})}
</fieldset>
)
}
return (
<div className="grid gap-1.5">
<Label className="sr-only">{widget.title || target}</Label>
@@ -700,6 +778,10 @@ const RENDERERS: Partial<
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
bar: BarWidget,
icon: IconWidget,
forecast: ForecastWidget,
clock: ClockWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,