Files
app/frontend/src/components/Dashboard/ForecastWidget.tsx
T
stroblme 59e942ca1c Draw something for an icon or colour name nothing maps to
An unknown name indexed the map, got undefined and drew nothing — the same
as no icon at all, so a typo was invisible. It resolves to a placeholder
glyph and the default tint now, and warns once per bad name: both lookups
sit in a render, so a bound widget would otherwise warn per value.

An empty name still draws nothing, which is what "no icon" means.
2026-08-30 16:29:46 +02:00

84 lines
3.1 KiB
TypeScript

import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { resolveIcon, resolveIconColor } from "./icons"
import { config } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
/** One column of a forecast, as the `list` message declares it. */
type ForecastItem = {
label?: string
icon?: string
value?: string | number
color?: string
}
/**
* What comes next, as a strip of columns over a `list` message.
*
* The shape is the widget's contract rather than a path per column: every item
* is `{label, icon, value}` with an optional `color`, naming its icon from the
* same vocabulary the icon widget offers, so a flow answering with a forecast
* decides what a step is called and this only has to draw it. Later columns are
* dimmed progressively — a forecast is less certain the further out it reads.
*/
export function ForecastWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = cfg.message ? String(cfg.message) : ""
const live = useBoundValue(message || undefined)
if (!message) {
return <p className="text-muted-foreground">Pick a message.</p>
}
const count = Number(cfg.count)
const items: ForecastItem[] = (Array.isArray(live?.value) ? live.value : [])
.filter((item): item is ForecastItem => !!item && typeof item === "object")
.slice(0, Number.isFinite(count) ? count : 5)
if (items.length === 0) {
return <p className="text-muted-foreground">Nothing forecast.</p>
}
return (
<div className="flex min-h-0 items-stretch justify-between gap-2">
{items.map((item, index) => {
const Glyph = resolveIcon(item.icon ?? "")
return (
<div
// Two steps can carry the same label and reading; position is the
// identity, and it is what the dimming is computed from.
key={`column-${index}`}
data-testid="forecast-column"
className="flex min-w-0 flex-col items-center gap-1"
// Content rather than a panel, so this fades the column as a whole
// instead of text over a surface; an inline value also keeps the
// ramp out of an arbitrary Tailwind class.
style={{ opacity: Math.max(0.45, 1 - index * 0.14) }}
>
<span
className="w-full truncate text-center text-muted-foreground"
// Relative to the widget's own size, like the ramp above, so an
// inline value rather than an arbitrary Tailwind class.
style={{ fontSize: "0.85em" }}
>
{item.label ?? ""}
</span>
{Glyph ? (
<Glyph
className={cn(
"dui-glyph-sm shrink-0",
resolveIconColor(item.color ?? ""),
)}
/>
) : null}
<span className="w-full truncate text-center tabular-nums">
{item.value === null || item.value === undefined
? ""
: String(item.value)}
</span>
</div>
)
})}
</div>
)
}