@hanzo/dashboard 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +245 -0
  3. package/package.json +38 -50
  4. package/src/analytics/index.ts +20 -0
  5. package/src/analytics/parts.tsx +91 -0
  6. package/src/analytics/product.tsx +64 -0
  7. package/src/analytics/source.ts +141 -0
  8. package/src/analytics/web.tsx +83 -0
  9. package/src/charts/Charts.tsx +470 -0
  10. package/src/dashboard.css +157 -0
  11. package/src/index.ts +119 -0
  12. package/src/landing/Hero.tsx +115 -0
  13. package/src/landing/Landing.tsx +47 -0
  14. package/src/landing/Metrics.tsx +134 -0
  15. package/src/landing/Rail.tsx +61 -0
  16. package/src/landing/Samples.tsx +78 -0
  17. package/src/landing/logic.ts +47 -0
  18. package/src/landing/parts.tsx +116 -0
  19. package/src/landing/types.ts +98 -0
  20. package/src/motion/hooks.ts +98 -0
  21. package/src/motion/motion.ts +76 -0
  22. package/src/overview/Overview.tsx +203 -0
  23. package/src/overview/config.ts +177 -0
  24. package/src/overview/logic.ts +204 -0
  25. package/src/overview/primitives.tsx +306 -0
  26. package/src/overview/superadmin.test.ts +36 -0
  27. package/src/overview/tiles.tsx +223 -0
  28. package/src/pipeline/Pipeline.tsx +178 -0
  29. package/src/pipeline/stages.ts +156 -0
  30. package/src/types.ts +12 -0
  31. package/readme.md +0 -57
  32. package/src/lib/api.ts +0 -155
  33. package/src/lib/components/ConfirmDialog.tsx +0 -53
  34. package/src/lib/components/TenantRow.tsx +0 -39
  35. package/src/lib/components/TenantStatusBadge.tsx +0 -33
  36. package/src/lib/index.ts +0 -30
  37. package/src/lib/nav.ts +0 -12
  38. package/src/lib/screens/NewTenantScreen.tsx +0 -137
  39. package/src/lib/screens/TenantsScreen.tsx +0 -229
@@ -0,0 +1,203 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Overview — the ONE reusable, videogame-like dashboard driver. Given an
5
+ * `OverviewConfig` (a product's tiles + its REAL data loader + live/motion
6
+ * settings) it renders the board and keeps it alive: a single throttled poll loop
7
+ * refetches on an interval (paused on a hidden tab), the range selector re-scopes,
8
+ * and every number counts up on change (unless reduced motion / count-up off).
9
+ *
10
+ * DRY + orthogonal: this file knows nothing about any specific product or endpoint.
11
+ * The tiles read their slice out of the normalized `OverviewData` the loader returns;
12
+ * a product is added by writing a config, never overview UI.
13
+ *
14
+ * Honest by construction: the FIRST load shows a spinner; a failure shows the error
15
+ * card; thereafter a background refetch never blanks the board (the last real data
16
+ * stays until new real data lands), and any tile whose slice is absent renders its
17
+ * own skeleton/empty. Style props are the @hanzo/gui v5 shorthand set.
18
+ */
19
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
20
+ import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
21
+
22
+ import { effectiveInterval } from '../motion/motion'
23
+ import { usePageHidden, usePoll } from '../motion/hooks'
24
+ import { type Loadable } from './logic'
25
+ import { PanelSpinner } from './primitives'
26
+ import { Tile } from './tiles'
27
+ import type { OverviewConfig, OverviewData, OverviewRange } from './config'
28
+
29
+ /** Throttle floor: a config can ask for faster, but never faster than this. */
30
+ const POLL_FLOOR_MS = 5000
31
+
32
+ const RANGES: { key: OverviewRange; label: string }[] = [
33
+ { key: '24h', label: '24H' },
34
+ { key: '7d', label: '7D' },
35
+ { key: '30d', label: '30D' },
36
+ ]
37
+
38
+ export function Overview({ config, allOrgs, superAdmin = false }: { config: OverviewConfig; allOrgs?: boolean; superAdmin?: boolean }) {
39
+ const [range, setRange] = useState<OverviewRange>('24h')
40
+ const [state, setState] = useState<Loadable<OverviewData>>({ s: 'loading' })
41
+ const [refreshing, setRefreshing] = useState(false)
42
+ const reqRef = useRef(0)
43
+
44
+ const hidden = usePageHidden()
45
+ // Pause polling while the tab is hidden OR the board is in its error state — an
46
+ // errored source should not be hammered on a loop; the user retries explicitly.
47
+ const pollMs = effectiveInterval(config.live?.pollMs ?? 0, POLL_FLOOR_MS, hidden || state.s === 'error')
48
+ const { tick, bump } = usePoll(pollMs)
49
+ const live = (config.live?.pollMs ?? 0) > 0
50
+ const countUp = config.live?.countUp !== false
51
+
52
+ // One load path for the first paint, the range change, and every poll tick. The
53
+ // reqRef guard drops a stale in-flight response so a fast range toggle can't
54
+ // render older data over newer.
55
+ useEffect(() => {
56
+ const id = ++reqRef.current
57
+ const first = state.s === 'loading'
58
+ if (!first) setRefreshing(true)
59
+ config
60
+ .load({ range, allOrgs, superAdmin })
61
+ .then((v) => {
62
+ if (id === reqRef.current) setState({ s: 'ready', v })
63
+ })
64
+ .catch((e) => {
65
+ // A background refetch that fails must NOT blank a board that already has
66
+ // real data — keep the last data, surface the error only on the first load.
67
+ if (id !== reqRef.current) return
68
+ if (first) {
69
+ const err = asLoadError(e)
70
+ setState({ s: 'error', message: err.message, status: err.status })
71
+ }
72
+ })
73
+ .finally(() => {
74
+ if (id === reqRef.current) setRefreshing(false)
75
+ })
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [range, allOrgs, superAdmin, tick, config])
78
+
79
+ const onRange = (k: OverviewRange) => {
80
+ setRange(k)
81
+ bump()
82
+ }
83
+
84
+ const data = state.s === 'ready' ? state.v : null
85
+
86
+ return (
87
+ <YStack gap="$4">
88
+ <Header
89
+ title={allOrgs ? `${config.title} — all orgs` : config.title}
90
+ subtitle={config.subtitle}
91
+ actions={
92
+ <XStack gap="$2" items="center">
93
+ {refreshing ? <Spinner size="small" color="$color10" /> : null}
94
+ {config.ranged === false ? null : <RangeSelector value={range} onChange={onRange} />}
95
+ </XStack>
96
+ }
97
+ />
98
+
99
+ {state.s === 'error' ? (
100
+ <ErrorState
101
+ message={state.message}
102
+ status={state.status}
103
+ onRetry={() => {
104
+ setState({ s: 'loading' })
105
+ bump()
106
+ }}
107
+ />
108
+ ) : state.s === 'loading' ? (
109
+ <PanelSpinner />
110
+ ) : (
111
+ <YStack gap="$4">
112
+ {config.rows.map((row, ri) => (
113
+ <FadeIn key={ri} index={ri} step={60}>
114
+ <XStack flexWrap="wrap" gap="$3">
115
+ {row.map((tile, ti) => (
116
+ <Tile key={ti} tile={tile} data={data} loading={false} live={live && countUp} index={ti} />
117
+ ))}
118
+ </XStack>
119
+ </FadeIn>
120
+ ))}
121
+ </YStack>
122
+ )}
123
+ </YStack>
124
+ )
125
+ }
126
+
127
+ // ── chrome (self-contained — no host-app coupling) ───────────────────────────
128
+
129
+ /** Extract an honest message + status from an unknown thrown value. */
130
+ function asLoadError(e: unknown): { message: string; status: number } {
131
+ if (e && typeof e === 'object') {
132
+ const anyE = e as { message?: unknown; status?: unknown }
133
+ return {
134
+ message: typeof anyE.message === 'string' ? anyE.message : 'Something went wrong.',
135
+ status: typeof anyE.status === 'number' ? anyE.status : 0,
136
+ }
137
+ }
138
+ return { message: typeof e === 'string' ? e : 'Something went wrong.', status: 0 }
139
+ }
140
+
141
+ /** Section title + optional subtitle and right-aligned actions. */
142
+ function Header({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
143
+ return (
144
+ <XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
145
+ <YStack gap="$1" flex={1} minW={200}>
146
+ <Text fontSize="$7" fontWeight="500" letterSpacing={-0.4}>
147
+ {title}
148
+ </Text>
149
+ {subtitle ? (
150
+ <Text fontSize="$3" color="$color11">
151
+ {subtitle}
152
+ </Text>
153
+ ) : null}
154
+ </YStack>
155
+ {actions ? <XStack gap="$2" items="center">{actions}</XStack> : null}
156
+ </XStack>
157
+ )
158
+ }
159
+
160
+ /** A fade-up entrance (staggered by index). Honors reduced motion via the CSS. */
161
+ function FadeIn({ children, index = 0, step = 50 }: { children: ReactNode; index?: number; step?: number }) {
162
+ return (
163
+ <div className="fade-up" style={{ animationDelay: `${index * step}ms` }}>
164
+ {children}
165
+ </div>
166
+ )
167
+ }
168
+
169
+ /** The first-load / retry error card (honest, with a retry). */
170
+ function ErrorState({ message, status, onRetry }: { message: string; status: number; onRetry: () => void }) {
171
+ const note =
172
+ status === 404
173
+ ? 'This overview’s data source is not routed on this deployment yet — it appears automatically once the backend is reachable for your org.'
174
+ : message
175
+ return (
176
+ <Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3" items="flex-start">
177
+ <Text fontSize="$5" fontWeight="600" color="$color12">
178
+ Could not load
179
+ </Text>
180
+ <Text fontSize="$3" color="$color11" maxW={620}>
181
+ {note}
182
+ </Text>
183
+ <Button onPress={onRetry}>Retry</Button>
184
+ </Card>
185
+ )
186
+ }
187
+
188
+ function RangeSelector({ value, onChange }: { value: OverviewRange; onChange: (k: OverviewRange) => void }) {
189
+ return (
190
+ <XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
191
+ {RANGES.map((r) => {
192
+ const active = r.key === value
193
+ return (
194
+ <Button key={r.key} size="$2" chromeless={!active} bg={active ? '$color5' : 'transparent'} rounded="$0" onPress={() => onChange(r.key)}>
195
+ <Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
196
+ {r.label}
197
+ </Text>
198
+ </Button>
199
+ )
200
+ })}
201
+ </XStack>
202
+ )
203
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Overview config — the DECLARATIVE contract that makes one overview
3
+ * component serve every product. A product declares WHICH tiles it has and WHERE
4
+ * their data comes from; `Overview` renders + animates them. No product
5
+ * writes overview UI — it writes a `OverviewConfig`.
6
+ *
7
+ * Orthogonal by design:
8
+ * - a `LoadOverview` is the ONE async that fetches a product's REAL data and
9
+ * returns a normalized `OverviewData` (KPIs, series, distribution, activity,
10
+ * alerts, health). It owns the endpoint; the tiles know nothing about it.
11
+ * - each tile is a small value object naming a slice of that data by key
12
+ * (`kpi.tokens`, `series.spend`, …). A missing slice renders its honest empty
13
+ * state — the config can over-declare tiles a slow backend hasn't filled yet.
14
+ * - `live` is the videogame layer: a poll interval (throttled, hidden-tab-paused)
15
+ * and whether numbers count up. Off → a static overview, same component.
16
+ *
17
+ * The consumer supplies its own adapter that maps each real source (usage ledger,
18
+ * admin aggregate, functions metrics, …) onto `OverviewData`, so wiring a product =
19
+ * one adapter + one config, never new UI.
20
+ */
21
+ import type { IconComponent } from '../types'
22
+
23
+ /** A KPI datum for an animated metric tile. Raw numbers; the tile formats them. */
24
+ export type OverviewKpi = {
25
+ value: number
26
+ /** Prior-period value for the ±% delta; omit when there is no basis (→ honest "—"). */
27
+ prior?: number
28
+ /** A recent dense series for the tile's live sparkline (oldest→newest). */
29
+ series?: number[]
30
+ }
31
+
32
+ /** A point on a temporal series. */
33
+ export type OverviewPoint = { t: string; value: number }
34
+
35
+ /** A named temporal series with its bucket interval (drives the x-axis labels). */
36
+ export type OverviewSeries = { interval: 'hour' | 'day' | (string & {}); points: OverviewPoint[] }
37
+
38
+ /** A slice of a distribution (spend by model, revenue by product). */
39
+ export type OverviewSlice = { label: string; value: number; sub?: string }
40
+
41
+ /** A live activity/event row. */
42
+ export type OverviewEvent = {
43
+ id: string
44
+ time: string
45
+ title: string
46
+ subtitle?: string
47
+ /** `success` | `error` | `warn` | '' — drives the status dot. */
48
+ status: string
49
+ }
50
+
51
+ /** An open alert. */
52
+ export type OverviewAlert = { id: string; severity: 'critical' | 'warning' | 'info' | (string & {}); title: string; detail?: string }
53
+
54
+ /** A service-health row. */
55
+ export type OverviewHealth = { service: string; health: 'green' | 'yellow' | 'red' | (string & {}); detail?: string }
56
+
57
+ /**
58
+ * The normalized data every tile reads. Keyed maps so a tile names its slice by a
59
+ * stable key; every map may be partial (a slice with no data → honest empty tile).
60
+ */
61
+ export type OverviewData = {
62
+ kpi: Record<string, OverviewKpi>
63
+ series: Record<string, OverviewSeries>
64
+ distribution: Record<string, OverviewSlice[]>
65
+ activity: OverviewEvent[]
66
+ alerts: OverviewAlert[]
67
+ health: OverviewHealth[]
68
+ /** Total count behind `activity` for pagination, when the source knows it. */
69
+ activityTotal?: number
70
+ }
71
+
72
+ /** How a number is displayed. `cents`→USD, `count`→compacted, `ms`→duration, `pct`→percent. */
73
+ export type MetricUnit = 'count' | 'cents' | 'ms' | 'pct'
74
+
75
+ /** An animated KPI tile: a count-up number + optional live sparkline + delta. */
76
+ export type MetricTile = {
77
+ tile: 'metric'
78
+ /** Key into `OverviewData.kpi`. */
79
+ key: string
80
+ label: string
81
+ icon: IconComponent
82
+ unit?: MetricUnit
83
+ /** Static caption under the value (e.g. "across 3 providers"). */
84
+ caption?: string
85
+ /** Accent color for the sparkline (defaults to the palette by position). */
86
+ color?: string
87
+ }
88
+
89
+ /** A timeseries panel (line or bars) over `OverviewData.series[key]`. */
90
+ export type TimeseriesTile = {
91
+ tile: 'timeseries'
92
+ key: string
93
+ title: string
94
+ kind?: 'line' | 'bar'
95
+ unit?: MetricUnit
96
+ }
97
+
98
+ /** A distribution panel (donut + legend) over `OverviewData.distribution[key]`. */
99
+ export type DistributionTile = {
100
+ tile: 'distribution'
101
+ key: string
102
+ title: string
103
+ /** Center caption under the total. */
104
+ centerLabel?: string
105
+ unit?: MetricUnit
106
+ }
107
+
108
+ /** The live activity stream (virtualized, streaming/polled). */
109
+ export type ActivityTile = {
110
+ tile: 'activity'
111
+ title?: string
112
+ /** Empty-state copy when there is no activity in range. */
113
+ empty?: string
114
+ }
115
+
116
+ /** The open-alerts panel. */
117
+ export type AlertsTile = { tile: 'alerts'; title?: string }
118
+
119
+ /** The service-health board. */
120
+ export type HealthTile = { tile: 'health'; title?: string; empty?: string }
121
+
122
+ /** Any tile. Discriminated on `tile` so the renderer branches exhaustively. */
123
+ export type OverviewTile =
124
+ | MetricTile
125
+ | TimeseriesTile
126
+ | DistributionTile
127
+ | ActivityTile
128
+ | AlertsTile
129
+ | HealthTile
130
+
131
+ /** The "videogame" layer — live updates + motion, all off-able for a static view. */
132
+ export type LiveConfig = {
133
+ /**
134
+ * Poll the loader every `pollMs` (throttled to a floor, paused when the tab is
135
+ * hidden). 0/undefined → no polling (a one-shot static overview).
136
+ */
137
+ pollMs?: number
138
+ /** Count numbers up on change (respects prefers-reduced-motion). Default true. */
139
+ countUp?: boolean
140
+ }
141
+
142
+ /** The context passed to a loader (range + org scope). */
143
+ export type OverviewContext = {
144
+ range: OverviewRange
145
+ /** SuperAdmin all-orgs god view. */
146
+ allOrgs?: boolean
147
+ /**
148
+ * The host's SuperAdmin verdict (a person whose home org is the reserved `admin`
149
+ * org, as the server or @hanzo/iam `superAdmin` states it — never derived here).
150
+ * Loaders use this to SKIP admin-only aggregates that a tenant user's browser
151
+ * would only ever get a 403 from, going straight to the org-scoped source
152
+ * instead — the board renders the same, minus the console noise.
153
+ */
154
+ superAdmin?: boolean
155
+ }
156
+
157
+ export type OverviewRange = '24h' | '7d' | '30d'
158
+
159
+ /** The ONE async that fetches a product's REAL data, normalized to `OverviewData`. */
160
+ export type LoadOverview = (ctx: OverviewContext) => Promise<OverviewData>
161
+
162
+ /**
163
+ * A product's complete overview — its header, its tiles (in render order), its
164
+ * real data loader, and its live/motion settings. This IS the reusable unit: a
165
+ * consumer builds one of these; `Overview` renders it.
166
+ */
167
+ export type OverviewConfig = {
168
+ id: string
169
+ title: string
170
+ subtitle?: string
171
+ /** Ordered rows of tiles (each row is a responsive wrap group). */
172
+ rows: OverviewTile[][]
173
+ load: LoadOverview
174
+ live?: LiveConfig
175
+ /** Show the range selector (24H/7D/30D). Default true. */
176
+ ranged?: boolean
177
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Overview tile logic — the PURE decisions behind every tile, so the honest
3
+ * empty/loading/error/reduced-motion behavior is unit-testable in the node env and
4
+ * the `.tsx` tiles stay thin.
5
+ *
6
+ * Every function here answers ONE question a tile needs: how to format a value for
7
+ * its unit, whether a live sparkline has enough real points to draw, what a delta
8
+ * reads (or the honest "—" when there's no basis), what color a status/health dot
9
+ * is, and — critically — how to SELECT a tile's slice out of `OverviewData` so a
10
+ * missing slice becomes an honest empty tile instead of a crash.
11
+ *
12
+ * Nothing here fabricates data: a formatter turns a real number into a string, a
13
+ * selector returns `undefined`/`[]` for absent data, and the sparkline gate returns
14
+ * false for <2 points (the tile then renders no trend).
15
+ */
16
+ import type {
17
+ MetricUnit,
18
+ OverviewData,
19
+ OverviewKpi,
20
+ OverviewSlice,
21
+ OverviewEvent,
22
+ OverviewHealth,
23
+ OverviewSeries,
24
+ } from './config'
25
+
26
+ // ── value formatting ─────────────────────────────────────────────────────────
27
+
28
+ const compact = (n: number): string =>
29
+ new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(n)
30
+
31
+ const usd = (cents: number): string => {
32
+ const d = cents / 100
33
+ if (Math.abs(d) >= 1000) return '$' + new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(d)
34
+ return '$' + d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
35
+ }
36
+
37
+ const duration = (ms: number): string => {
38
+ if (ms >= 1000) return (ms / 1000).toFixed(ms >= 10_000 ? 0 : 1) + 's'
39
+ return Math.round(ms) + 'ms'
40
+ }
41
+
42
+ /** Format a raw metric value for its unit. The ONE place units become strings. */
43
+ export function formatMetric(value: number, unit: MetricUnit = 'count'): string {
44
+ if (!Number.isFinite(value)) return '—'
45
+ switch (unit) {
46
+ case 'cents':
47
+ return usd(value)
48
+ case 'ms':
49
+ return duration(value)
50
+ case 'pct':
51
+ return `${Math.round(value)}%`
52
+ default:
53
+ return compact(value)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The number the count-up animation should target for a unit. Money counts up in
59
+ * DOLLARS (not cents) so the animated digits read naturally; everything else counts
60
+ * up in its own value. The tile formats the interpolated number back via
61
+ * `formatMetric` on the same unit. This returns the raw value; the divisor is in
62
+ * `formatMetric`.
63
+ */
64
+ export function metricTarget(value: number): number {
65
+ return Number.isFinite(value) ? value : 0
66
+ }
67
+
68
+ /** A delta descriptor for a KPI, or null when there's no prior basis (honest "—"). */
69
+ export function deltaOf(kpi: OverviewKpi | undefined): { pct: number; up: boolean } | null {
70
+ if (!kpi || kpi.prior === undefined || !Number.isFinite(kpi.prior) || kpi.prior === 0) return null
71
+ const pct = Math.round(((kpi.value - kpi.prior) / kpi.prior) * 100)
72
+ return { pct, up: pct >= 0 }
73
+ }
74
+
75
+ /** True when a live sparkline has ≥2 finite points to draw (else the tile shows no trend). */
76
+ export function hasTrend(series: readonly number[] | undefined): boolean {
77
+ if (!series) return false
78
+ return series.filter((v) => Number.isFinite(v)).length >= 2
79
+ }
80
+
81
+ // ── status / health colors (the ONE mapping) ─────────────────────────────────
82
+
83
+ export const OK = '#23c562'
84
+ export const WARN = '#f0a868'
85
+ export const BAD = '#ff5d8f'
86
+ export const MUTED = '#64748b'
87
+
88
+ /** Dot color for an activity/event status. */
89
+ export function statusColor(status: string): string {
90
+ const s = status.toLowerCase()
91
+ if (s === 'error' || s === 'failed' || s === 'fail') return BAD
92
+ if (s === 'warn' || s === 'warning' || s === 'pending') return WARN
93
+ return OK // success or unknown/'' reads as nominal
94
+ }
95
+
96
+ /** Dot color for a service-health verdict. */
97
+ export function healthColor(health: string): string {
98
+ const h = health.toLowerCase()
99
+ if (h === 'green' || h === 'healthy' || h === 'ok') return OK
100
+ if (h === 'yellow' || h === 'degraded' || h === 'warn') return WARN
101
+ if (h === 'red' || h === 'down' || h === 'unhealthy') return BAD
102
+ return MUTED
103
+ }
104
+
105
+ /** Dot color for an alert severity. */
106
+ export function severityColor(severity: string): string {
107
+ const s = severity.toLowerCase()
108
+ if (s === 'critical' || s === 'error') return BAD
109
+ if (s === 'warning' || s === 'warn') return WARN
110
+ return MUTED
111
+ }
112
+
113
+ // ── overall health verdict for the board header ──────────────────────────────
114
+
115
+ /** The worst verdict across health rows: red > yellow > green; '' when no rows. */
116
+ export function worstHealth(rows: readonly OverviewHealth[]): '' | 'green' | 'yellow' | 'red' {
117
+ let sawYellow = false
118
+ let sawGreen = false
119
+ for (const r of rows) {
120
+ const h = r.health.toLowerCase()
121
+ if (h === 'red' || h === 'down' || h === 'unhealthy') return 'red' // worst — short-circuit
122
+ if (h === 'yellow' || h === 'degraded') sawYellow = true
123
+ else if (h === 'green' || h === 'healthy' || h === 'ok') sawGreen = true
124
+ }
125
+ if (sawYellow) return 'yellow'
126
+ if (sawGreen) return 'green'
127
+ return ''
128
+ }
129
+
130
+ /** Count healthy rows / total (for the "12/14 healthy" header). */
131
+ export function healthTally(rows: readonly OverviewHealth[]): { healthy: number; total: number } {
132
+ const total = rows.length
133
+ const healthy = rows.filter((r) => healthColor(r.health) === OK).length
134
+ return { healthy, total }
135
+ }
136
+
137
+ // ── slice selectors (a missing slice → honest empty, never a crash) ──────────
138
+
139
+ export const selectKpi = (data: OverviewData, key: string): OverviewKpi | undefined => data.kpi[key]
140
+ export const selectSeries = (data: OverviewData, key: string): OverviewSeries | undefined => data.series[key]
141
+ export const selectDistribution = (data: OverviewData, key: string): OverviewSlice[] => data.distribution[key] ?? []
142
+
143
+ /** Sum a distribution's positive values (the donut center total). */
144
+ export function distributionTotal(slices: readonly OverviewSlice[]): number {
145
+ return slices.reduce((sum, s) => sum + Math.max(0, s.value), 0)
146
+ }
147
+
148
+ /**
149
+ * Merge freshly-polled activity into the current list, newest-first, de-duped by
150
+ * id, capped. This is what lets the live stream GROW smoothly across polls without
151
+ * duplicating a row already on screen or re-ordering the feed. Rows with an empty
152
+ * id are kept (can't dedupe them) but still ordered by time.
153
+ */
154
+ export function mergeActivity(current: readonly OverviewEvent[], incoming: readonly OverviewEvent[], cap: number): OverviewEvent[] {
155
+ const seen = new Set<string>()
156
+ const out: OverviewEvent[] = []
157
+ for (const e of [...incoming, ...current]) {
158
+ if (e.id && seen.has(e.id)) continue
159
+ if (e.id) seen.add(e.id)
160
+ out.push(e)
161
+ }
162
+ out.sort((a, b) => tOf(b.time) - tOf(a.time))
163
+ return out.slice(0, cap)
164
+ }
165
+
166
+ const tOf = (iso: string): number => {
167
+ const t = Date.parse(iso)
168
+ return Number.isNaN(t) ? 0 : t
169
+ }
170
+
171
+ /** Which rows of a virtualized list are visible for a scroll offset + viewport. */
172
+ export function windowRows<T>(rows: readonly T[], scrollTop: number, rowH: number, viewportH: number, overscan = 4): { start: number; end: number; padTop: number; padBottom: number; slice: T[] } {
173
+ if (rows.length === 0 || rowH <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0, slice: [] }
174
+ const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan)
175
+ const visible = Math.ceil(viewportH / rowH) + overscan * 2
176
+ const end = Math.min(rows.length, start + visible)
177
+ return {
178
+ start,
179
+ end,
180
+ padTop: start * rowH,
181
+ padBottom: Math.max(0, (rows.length - end) * rowH),
182
+ slice: rows.slice(start, end),
183
+ }
184
+ }
185
+
186
+ // ── async state (the repo idiom, shared by the driver + tests) ───────────────
187
+
188
+ export type Loadable<T> = { s: 'loading' } | { s: 'error'; message: string; status: number } | { s: 'ready'; v: T }
189
+
190
+ /** True when a tile should render its SKELETON (first load, no data yet). */
191
+ export const isSkeleton = <T>(state: Loadable<T>): boolean => state.s === 'loading'
192
+
193
+ /** Relative-time label for an event/activity timestamp (honest for a bad stamp). */
194
+ export function ago(iso: string, now: number): string {
195
+ const t = Date.parse(iso)
196
+ if (Number.isNaN(t)) return '—'
197
+ const s = Math.max(0, Math.round((now - t) / 1000))
198
+ if (s < 60) return `${s}s ago`
199
+ const m = Math.round(s / 60)
200
+ if (m < 60) return `${m}m ago`
201
+ const h = Math.round(m / 60)
202
+ if (h < 24) return `${h}h ago`
203
+ return `${Math.round(h / 24)}d ago`
204
+ }