diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py index 3d68a61..cc9314d 100644 --- a/backend/app/flow/dashboards.py +++ b/backend/app/flow/dashboards.py @@ -42,6 +42,10 @@ WidgetType = Literal[ "markdown", "agenda", "notification", + "bar", + "icon", + "forecast", + "clock", # Input "button", "switch", @@ -66,6 +70,10 @@ WIDGET_DTYPES: dict[str, set[str]] = { "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. } @@ -124,7 +132,8 @@ class WidgetDef(BaseModel): if series.get("message") ] name = self.config.get("message") - return [str(name)] if name else [] + inner = self.config.get("inner") # only a bar nests a second reading + return [str(value) for value in (name, inner) if value] @property def target(self) -> str: @@ -161,7 +170,7 @@ class WidgetDef(BaseModel): str(series.get("dtype") or "") for series in self.config.get("series") or [] ] - return [str(self.config.get("dtype") or "")] + return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")] @model_validator(mode="after") def _check_binding(self) -> WidgetDef: diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 1e0aa07..4c9f07f 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -213,6 +213,7 @@ def test_a_widget_refuses_a_dtype_it_cannot_carry(): def test_the_structured_widgets_bind_their_shapes(): WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"}) WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"}) + WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "list"}) with pytest.raises(ValueError): WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"}) @@ -220,6 +221,41 @@ def test_the_structured_widgets_bind_their_shapes(): WidgetDef( id="n", type="notification", config={"message": "a.b", "dtype": "list"} ) + with pytest.raises(ValueError): + WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "json"}) + + +def test_a_bar_nests_a_second_number(): + WidgetDef( + id="b", + type="bar", + config={ + "message": "a.in", + "dtype": "float", + "inner": "a.pv", + "inner_dtype": "int", + }, + ) + + with pytest.raises(ValueError): + WidgetDef( + id="b", + type="bar", + config={"message": "a.in", "dtype": "float", "inner_dtype": "bool"}, + ) + + +def test_a_bar_is_drawn_on_both_readings_it_nests(): + widget = WidgetDef(id="b", type="bar", config={"message": "a.in", "inner": "a.pv"}) + + assert widget.messages == ["a.in", "a.pv"] + + +def test_a_clock_reads_nothing_and_publishes_nothing(): + widget = WidgetDef(id="c", type="clock", config={"format": "24h"}) + + assert widget.messages == [] + assert widget.target == "" def test_a_querying_chart_asks_with_a_record_and_draws_a_series(): diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index dad9fc0..09df90d 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2907,7 +2907,7 @@ export const WidgetDefSchema = { }, type: { type: 'string', - enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'], + enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'button', 'switch', 'slider', 'input', 'dropdown'], title: 'Type' }, title: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index ba35a1d..78cd8a2 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -967,7 +967,7 @@ export type ValidationResult = { */ export type WidgetDef = { id: string; - type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown'; + type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown'; title?: string; layout?: { [key: string]: Placement; @@ -977,7 +977,7 @@ export type WidgetDef = { }; }; -export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown'; +export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown'; export type WorkerInfo = { name: string; diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx new file mode 100644 index 0000000..be8c23f --- /dev/null +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -0,0 +1,6 @@ +import type { WidgetProps } from "./widgets" + +/** A bar — a stub until the bar widget is written. */ +export function BarWidget(_props: WidgetProps) { + return

+} diff --git a/frontend/src/components/Dashboard/ClockWidget.tsx b/frontend/src/components/Dashboard/ClockWidget.tsx new file mode 100644 index 0000000..bef1b89 --- /dev/null +++ b/frontend/src/components/Dashboard/ClockWidget.tsx @@ -0,0 +1,6 @@ +import type { WidgetProps } from "./widgets" + +/** A clock — a stub until the clock widget is written. */ +export function ClockWidget(_props: WidgetProps) { + return

+} diff --git a/frontend/src/components/Dashboard/ForecastWidget.tsx b/frontend/src/components/Dashboard/ForecastWidget.tsx new file mode 100644 index 0000000..62b8baa --- /dev/null +++ b/frontend/src/components/Dashboard/ForecastWidget.tsx @@ -0,0 +1,6 @@ +import type { WidgetProps } from "./widgets" + +/** A forecast — a stub until the forecast widget is written. */ +export function ForecastWidget(_props: WidgetProps) { + return

+} diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx new file mode 100644 index 0000000..3afe09a --- /dev/null +++ b/frontend/src/components/Dashboard/IconWidget.tsx @@ -0,0 +1,6 @@ +import type { WidgetProps } from "./widgets" + +/** An icon — a stub until the icon widget is written. */ +export function IconWidget(_props: WidgetProps) { + return

+} diff --git a/frontend/src/components/Dashboard/icons.ts b/frontend/src/components/Dashboard/icons.ts new file mode 100644 index 0000000..ad27279 --- /dev/null +++ b/frontend/src/components/Dashboard/icons.ts @@ -0,0 +1,90 @@ +import { + ArrowDown, + ArrowUp, + BatteryCharging, + Bed, + Check, + CircleCheck, + CloudDrizzle, + CloudFog, + CloudLightning, + CloudRain, + CloudSnow, + Cloudy, + DoorOpen, + Droplets, + Fan, + Flame, + House, + Lightbulb, + type LucideIcon, + Moon, + Plug, + Snowflake, + Sun, + SunSnow, + Thermometer, + ThermometerSun, + TriangleAlert, + Umbrella, + Wind, + Zap, +} from "lucide-react" + +/** + * The icons a tile may be drawn with, by name. + * + * Curated rather than lucide's `dynamicIconImports`: the panel has to offer + * these in a list, and the dynamic map makes the bundler emit a lazy chunk per + * icon — some fifteen hundred of them — so a wall panel would fetch one request + * per tile over its LAN before it could draw anything. A few dozen weather, + * room and status glyphs cover what a dashboard says, and the map is cheap to + * extend. + */ +export const ICONS: Record = { + sun: Sun, + moon: Moon, + cloudy: Cloudy, + "cloud-rain": CloudRain, + "cloud-drizzle": CloudDrizzle, + "cloud-snow": CloudSnow, + "cloud-lightning": CloudLightning, + "cloud-fog": CloudFog, + wind: Wind, + umbrella: Umbrella, + snowflake: Snowflake, + droplets: Droplets, + thermometer: Thermometer, + "thermometer-sun": ThermometerSun, + flame: Flame, + fan: Fan, + "sun-snow": SunSnow, + "door-open": DoorOpen, + house: House, + bed: Bed, + lightbulb: Lightbulb, + plug: Plug, + zap: Zap, + "battery-charging": BatteryCharging, + "arrow-up": ArrowUp, + "arrow-down": ArrowDown, + "triangle-alert": TriangleAlert, + check: Check, + "circle-check": CircleCheck, +} + +export const ICON_NAMES = Object.keys(ICONS) + +/** + * What an icon may be tinted, by name. + * + * Literal classes so Tailwind's scanner sees them. No terracotta: that is the + * one affordance a view gets, and a panel is many tiles. + */ +export const ICON_COLORS: Record = { + default: "text-foreground", + muted: "text-muted-foreground", + primary: "text-primary", + success: "text-status-success", + danger: "text-destructive", +} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 05aa623..0e7e592 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -156,6 +156,49 @@ function ModePicker({ ) } +/** Which chrome an input wears, in the same segmented shape as the mode. */ +function StylePicker({ + value, + options, + onChange, +}: { + value: string + options: readonly (readonly [string, string])[] + onChange: (style: string) => void +}) { + return ( +
+ How this control is drawn + {options.map(([style, label]) => ( + + ))} +
+ ) +} + +/** What a text field was meant to send: a bool, a number, or the text itself. */ +function coerce(raw: string): unknown { + const asNumber = Number(raw) + if (raw === "true" || raw === "false") return raw === "true" + return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw +} + /** * What one widget shows or does. * @@ -183,6 +226,9 @@ export function WidgetPanel({ const series = seriesOf(widget) const setSeries = (next: Series[]) => set({ series: next }) + // Nothing wrote these until now, so a dropdown's choices were uneditable. + const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] + const setOptions = (next: typeof options) => set({ options: next }) const querying = cfg.source === "query" // What this chart would refresh at with nothing configured. The viewer can // pick another window on the widget, which moves it. @@ -334,7 +380,8 @@ export function WidgetPanel({ )} - ) : ( + ) : // A clock reads the wall; a picker would bind a message nothing reads. + widget.type === "clock" ? null : ( { - const raw = event.target.value - const asNumber = Number(raw) - set({ - value: - raw === "true" || raw === "false" - ? raw === "true" - : raw !== "" && Number.isFinite(asNumber) - ? asNumber - : raw, - }) - }} + onChange={(event) => set({ value: coerce(event.target.value) })} /> ) : null} + + {widget.type === "dropdown" ? ( +
+ Options + {options.map((option, index) => ( +
+ + setOptions( + options.map((other, at) => + at === index + ? { ...other, label: event.target.value } + : other, + ), + ) + } + /> + + setOptions( + options.map((other, at) => + at === index + ? { ...other, value: coerce(event.target.value) } + : other, + ), + ) + } + /> + +
+ ))} + +
+ ) : null} + + {widget.type === "switch" || widget.type === "dropdown" ? ( +
+ + {widget.type === "switch" ? ( + set({ style })} + /> + ) : ( + set({ style })} + /> + )} +
+ ) : null} ) diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 12fa97a..d63d979 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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> = { 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 = { 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 = { 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 - return ( + + const on = live?.value === true + return cfg.style === "button" ? ( + + ) : (
- {live?.value === true ? "On" : "Off"} + {on ? "On" : "Off"} 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 + if (cfg.style === "segmented") { + return ( + // The one segmented shape: a single border pill, no dividers, + // transparent segments, bg-accent on the selected one. +
+ {widget.title || target} + {options.map((option) => { + const selected = text(option.value) === text(live?.value) + return ( + + ) + })} +
+ ) + } + return (
@@ -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,