From 7bba04e1ceadb42e7c2c50086c3c34169451b0af Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 08:35:34 +0200 Subject: [PATCH] Add the icon-by-value widget: first matching rule picks glyph, colour and label --- .../src/components/Dashboard/IconWidget.tsx | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Dashboard/IconWidget.tsx b/frontend/src/components/Dashboard/IconWidget.tsx index 3afe09a..1c8ec7d 100644 --- a/frontend/src/components/Dashboard/IconWidget.tsx +++ b/frontend/src/components/Dashboard/IconWidget.tsx @@ -1,6 +1,70 @@ +import type { WidgetDef } from "@/client" +import { useLiveValue } from "@/components/Flow/liveStore" +import { cn } from "@/lib/utils" +import { ICON_COLORS, ICONS } from "./icons" import type { WidgetProps } from "./widgets" -/** An icon — a stub until the icon widget is written. */ -export function IconWidget(_props: WidgetProps) { - return

+/** One row of the mapping, as the document stores it. */ +type Rule = { at?: unknown; icon?: string; color?: string; label?: string } + +const config = (widget: WidgetDef) => + (widget.config ?? {}) as Record + +const text = (value: unknown, fallback = "") => + value === null || value === undefined ? fallback : String(value) + +/** What a mapped value was meant to be: 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 +} + +/** + * Whether a row claims this reading. + * + * Equality, plus `>=` when both sides are numbers: a ladder written from the + * top down then reads as thresholds, one field per row instead of a pair. + */ +const matches = (value: unknown, at: unknown) => + value === at || + (typeof value === "number" && typeof at === "number" && value >= at) + +/** + * A glyph picked by what a message says. + * + * Rows are checked top to bottom and the first match wins, so a weather + * condition, a boolean hint and a threshold ladder all fit the same widget. + */ +export function IconWidget({ widget }: WidgetProps) { + const cfg = config(widget) + const message = text(cfg.message) + const live = useLiveValue(message || undefined) + + const value = live?.value + const rules = (cfg.rules ?? []) as Rule[] + const matched = + value === null || value === undefined + ? undefined + : rules.find((rule) => matches(value, coerce(text(rule.at)))) + const Glyph = ICONS[matched?.icon ?? text(cfg.icon)] ?? null + + // Unbound, unmatched, or pointed at a glyph that is not in the map. + if (!Glyph) return

+ + return ( +
+ + {matched?.label ? ( + + {matched.label} + + ) : null} +
+ ) }