@startsimpli/ui 0.4.50 → 0.4.51

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.50",
3
+ "version": "0.4.51",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -40,8 +40,8 @@
40
40
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
41
41
  "react": "^18.0.0 || ^19.0.0",
42
42
  "react-dom": "^18.0.0 || ^19.0.0",
43
- "@startsimpli/auth": "0.4.29",
44
- "@startsimpli/api": "0.5.44"
43
+ "@startsimpli/api": "0.5.44",
44
+ "@startsimpli/auth": "0.4.29"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "@startsimpli/api": {
@@ -74,6 +74,52 @@ export const UI_CATALOG: CatalogEntry[] = [
74
74
  source: { export: 'KanbanBoard', from: '@startsimpli/ui' },
75
75
  configExample: { kind: 'kanban-board', type: '<typeKey>', title: 'Board' },
76
76
  },
77
+ {
78
+ id: 'pipeline-health',
79
+ category: 'widget',
80
+ title: 'Pipeline health',
81
+ description:
82
+ 'A compact health card for one status type: how many records sit in each stage, plus a stuck/aging flag that pulls the card to a warning tone. Use on a system-health home to bubble up a content type\'s throughput at a glance (the pipeline funnel shows conversion; this shows health).',
83
+ dataNeeds: 'A type with a status/enum field.',
84
+ source: { export: 'PipelineHealth', from: '@startsimpli/ui' },
85
+ configExample: { kind: 'pipeline-health', type: '<typeKey>', title: 'Pipeline health' },
86
+ },
87
+ {
88
+ id: 'source-freshness',
89
+ category: 'widget',
90
+ title: 'Source freshness',
91
+ description:
92
+ 'A health card listing upstream sources as fresh vs stale by their last-updated time — any stale feed pulls the card to a warning. Use to prove ongoing ingestion is actually flowing (a stale source is a bug, not coverage).',
93
+ dataNeeds: 'A type whose records carry a last-updated / last-fetched timestamp.',
94
+ source: { export: 'SourceFreshness', from: '@startsimpli/ui' },
95
+ configExample: { kind: 'source-freshness', type: '<typeKey>', title: 'Source freshness' },
96
+ },
97
+ {
98
+ id: 'delivery-health',
99
+ category: 'widget',
100
+ title: 'Delivery health',
101
+ description:
102
+ 'A health card for scheduled deliverables: on-time / late / missed / upcoming counts derived from a type\'s scheduled and delivered date fields. Use for recurring outputs like weekly briefs.',
103
+ dataNeeds: 'A type with a scheduled-date and a delivered-date field.',
104
+ source: { export: 'DeliveryHealth', from: '@startsimpli/ui' },
105
+ configExample: {
106
+ kind: 'delivery-health',
107
+ type: '<typeKey>',
108
+ scheduledField: '<scheduled>',
109
+ deliveredField: '<delivered>',
110
+ title: 'Delivery',
111
+ },
112
+ },
113
+ {
114
+ id: 'attention',
115
+ category: 'widget',
116
+ title: 'Needs attention',
117
+ description:
118
+ 'A cross-type queue of records that need a human — stuck records, drafts awaiting review. Empty is the healthy state. Use as the "what should I look at" panel on a system-health home.',
119
+ dataNeeds: 'Any types (flags records by status / age).',
120
+ source: { export: 'AttentionFeed', from: '@startsimpli/ui' },
121
+ configExample: { kind: 'attention', limit: 8, title: 'Needs attention' },
122
+ },
77
123
  {
78
124
  id: 'data-table',
79
125
  category: 'view',
@@ -0,0 +1,95 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { Inbox } from 'lucide-react'
4
+ import { formatRelativeTime } from '../../utils'
5
+ import { HealthCard, HealthDot } from './HealthCard'
6
+ import { worstStatus, type HealthStatus } from './health-status'
7
+
8
+ export interface AttentionItem {
9
+ id: string
10
+ label: string
11
+ /** Optional context line, e.g. "Weekly brief · awaiting review". */
12
+ meta?: string
13
+ /** Link target; when set the row is a link. */
14
+ href?: string
15
+ /** Row tone (default warn — it's in the attention queue for a reason). */
16
+ tone?: HealthStatus
17
+ /** Timestamp shown as relative time on the right. */
18
+ at?: string | Date | null
19
+ }
20
+
21
+ export interface AttentionFeedProps {
22
+ title?: string
23
+ items: AttentionItem[]
24
+ limit?: number
25
+ emptyMessage?: string
26
+ className?: string
27
+ }
28
+
29
+ /**
30
+ * The "needs a human" queue: records flagged for review across every type. Empty
31
+ * is the healthy state (all clear); anything in the queue pulls the card to warn
32
+ * (or worse, per item tone). Presentational — a tenant widget collects the items
33
+ * from whatever it considers actionable (stuck records, drafts awaiting review…).
34
+ */
35
+ export function AttentionFeed({
36
+ title = 'Needs attention',
37
+ items,
38
+ limit,
39
+ emptyMessage = 'All clear — nothing needs review.',
40
+ className,
41
+ }: AttentionFeedProps) {
42
+ const status: HealthStatus =
43
+ items.length === 0 ? 'ok' : worstStatus(items.map((it) => it.tone ?? 'warn'))
44
+ const statusLabel = items.length === 0 ? 'All clear' : `${items.length} to review`
45
+ const listed = limit ? items.slice(0, limit) : items
46
+
47
+ return (
48
+ <HealthCard
49
+ title={title}
50
+ status={status}
51
+ statusLabel={statusLabel}
52
+ icon={Inbox}
53
+ isEmpty={items.length === 0}
54
+ emptyMessage={emptyMessage}
55
+ className={className}
56
+ >
57
+ <ul className="divide-y divide-gray-100" aria-label={`${title} items`}>
58
+ {listed.map((item) => {
59
+ const body = (
60
+ <span className="flex items-start justify-between gap-3 py-2">
61
+ <span className="flex items-start gap-2 min-w-0">
62
+ <HealthDot status={item.tone ?? 'warn'} className="mt-1.5" />
63
+ <span className="min-w-0">
64
+ <span className="block text-sm text-gray-900 truncate">{item.label}</span>
65
+ {item.meta && <span className="block text-xs text-gray-500 truncate">{item.meta}</span>}
66
+ </span>
67
+ </span>
68
+ {item.at && (
69
+ <span className="text-xs text-gray-400 tabular-nums flex-shrink-0 pt-0.5">
70
+ {formatRelativeTime(item.at)}
71
+ </span>
72
+ )}
73
+ </span>
74
+ )
75
+ return (
76
+ <li key={item.id}>
77
+ {item.href ? (
78
+ <a
79
+ href={item.href}
80
+ className={cn(
81
+ 'block rounded px-1 -mx-1 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500',
82
+ )}
83
+ >
84
+ {body}
85
+ </a>
86
+ ) : (
87
+ body
88
+ )}
89
+ </li>
90
+ )
91
+ })}
92
+ </ul>
93
+ </HealthCard>
94
+ )
95
+ }
@@ -0,0 +1,127 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { CalendarClock } from 'lucide-react'
4
+ import { formatRelativeTime } from '../../utils'
5
+ import { HealthCard, HealthDot } from './HealthCard'
6
+ import { worstStatus, type HealthStatus } from './health-status'
7
+
8
+ export type DeliveryState = 'on_time' | 'upcoming' | 'late' | 'missed'
9
+
10
+ export interface DeliveryItem {
11
+ label: string
12
+ /** Explicit state; otherwise derived from due/delivered vs now. */
13
+ status?: DeliveryState
14
+ dueAt?: string | Date | null
15
+ deliveredAt?: string | Date | null
16
+ }
17
+
18
+ export interface DeliveryHealthProps {
19
+ title?: string
20
+ items: DeliveryItem[]
21
+ /** Compare against this instant (injectable for tests); defaults to Date.now(). */
22
+ now?: number
23
+ /** Cap the number of rows listed (summary counts still cover all). */
24
+ limit?: number
25
+ emptyMessage?: string
26
+ className?: string
27
+ }
28
+
29
+ const STATE_TONE: Record<DeliveryState, HealthStatus> = {
30
+ on_time: 'ok',
31
+ upcoming: 'neutral',
32
+ late: 'warn',
33
+ missed: 'critical',
34
+ }
35
+
36
+ const STATE_LABEL: Record<DeliveryState, string> = {
37
+ on_time: 'On time',
38
+ upcoming: 'Upcoming',
39
+ late: 'Late',
40
+ missed: 'Missed',
41
+ }
42
+
43
+ function deriveState(item: DeliveryItem, now: number): DeliveryState {
44
+ if (item.status) return item.status
45
+ const due = item.dueAt ? new Date(item.dueAt).getTime() : null
46
+ if (item.deliveredAt) {
47
+ const delivered = new Date(item.deliveredAt).getTime()
48
+ return due !== null && delivered > due ? 'late' : 'on_time'
49
+ }
50
+ if (due === null) return 'upcoming'
51
+ return due < now ? 'missed' : 'upcoming'
52
+ }
53
+
54
+ /**
55
+ * Delivery / schedule health: were scheduled deliverables (e.g. weekly briefs)
56
+ * shipped on time? Missed/late deliveries drive the card critical/warn; upcoming
57
+ * ones are informational. Presentational — a tenant widget derives states from a
58
+ * type's scheduled_for / delivered_at fields.
59
+ */
60
+ export function DeliveryHealth({
61
+ title = 'Delivery',
62
+ items,
63
+ now,
64
+ limit,
65
+ emptyMessage = 'Nothing scheduled.',
66
+ className,
67
+ }: DeliveryHealthProps) {
68
+ const at = now ?? Date.now()
69
+ const rows = items.map((it) => ({ ...it, state: deriveState(it, at) }))
70
+ const counts = rows.reduce(
71
+ (acc, r) => ((acc[r.state] += 1), acc),
72
+ { on_time: 0, upcoming: 0, late: 0, missed: 0 } as Record<DeliveryState, number>,
73
+ )
74
+ const status = worstStatus(rows.map((r) => STATE_TONE[r.state]))
75
+ const problems = counts.late + counts.missed
76
+ const statusLabel =
77
+ items.length === 0 ? undefined : problems > 0 ? `${problems} off-track` : 'On track'
78
+
79
+ const listed = limit ? rows.slice(0, limit) : rows
80
+
81
+ return (
82
+ <HealthCard
83
+ title={title}
84
+ status={status}
85
+ statusLabel={statusLabel}
86
+ icon={CalendarClock}
87
+ isEmpty={items.length === 0}
88
+ emptyMessage={emptyMessage}
89
+ className={className}
90
+ >
91
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
92
+ {(['on_time', 'late', 'missed', 'upcoming'] as DeliveryState[])
93
+ .filter((s) => counts[s] > 0)
94
+ .map((s) => (
95
+ <span key={s} className="inline-flex items-center gap-1.5">
96
+ <HealthDot status={STATE_TONE[s]} />
97
+ <span className="tabular-nums font-medium text-gray-900">{counts[s]}</span> {STATE_LABEL[s]}
98
+ </span>
99
+ ))}
100
+ </div>
101
+
102
+ <ul className="space-y-2" aria-label={`${title} items`}>
103
+ {listed.map((row, i) => (
104
+ <li key={`${row.label}-${i}`} className="flex items-center justify-between gap-3 text-sm">
105
+ <span className="flex items-center gap-2 min-w-0">
106
+ <HealthDot status={STATE_TONE[row.state]} />
107
+ <span className="text-gray-700 truncate">{row.label}</span>
108
+ </span>
109
+ <span
110
+ className={cn(
111
+ 'text-xs tabular-nums flex-shrink-0',
112
+ STATE_TONE[row.state] === 'ok' ? 'text-gray-500' : 'text-gray-900 font-medium',
113
+ )}
114
+ >
115
+ {STATE_LABEL[row.state]}
116
+ {row.deliveredAt
117
+ ? ` · ${formatRelativeTime(row.deliveredAt)}`
118
+ : row.dueAt
119
+ ? ` · ${formatRelativeTime(row.dueAt)}`
120
+ : ''}
121
+ </span>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </HealthCard>
126
+ )
127
+ }
@@ -0,0 +1,108 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { HEALTH_TONE, type HealthStatus } from './health-status'
4
+
5
+ /** A small solid status dot. Pair it with a label — never rely on color alone. */
6
+ export function HealthDot({ status, className }: { status: HealthStatus; className?: string }) {
7
+ return (
8
+ <span
9
+ className={cn('inline-block w-2.5 h-2.5 rounded-full flex-shrink-0', HEALTH_TONE[status].dot, className)}
10
+ aria-hidden="true"
11
+ />
12
+ )
13
+ }
14
+
15
+ /** A tone pill: icon + label, used in a HealthCard header to name the roll-up state. */
16
+ export function HealthBadge({
17
+ status,
18
+ label,
19
+ className,
20
+ }: {
21
+ status: HealthStatus
22
+ label?: string
23
+ className?: string
24
+ }) {
25
+ const tone = HEALTH_TONE[status]
26
+ const Icon = tone.Icon
27
+ return (
28
+ <span
29
+ className={cn(
30
+ 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
31
+ tone.softBg,
32
+ tone.text,
33
+ className,
34
+ )}
35
+ >
36
+ <Icon className="w-3.5 h-3.5" aria-hidden="true" />
37
+ {label ?? tone.label}
38
+ </span>
39
+ )
40
+ }
41
+
42
+ export interface HealthCardProps {
43
+ /** Card heading. */
44
+ title: string
45
+ /** The rolled-up tone — drives the left accent + the header badge. */
46
+ status: HealthStatus
47
+ /** Optional short label overriding the tone's default badge text (e.g. "3 stale"). */
48
+ statusLabel?: string
49
+ /** Optional leading icon for the title. */
50
+ icon?: React.ComponentType<{ className?: string }>
51
+ /** Optional right-aligned header slot (a link, a period selector…). */
52
+ action?: React.ReactNode
53
+ /** Body — the signal-specific content. */
54
+ children?: React.ReactNode
55
+ /** Shown (muted, centered) when there is nothing to report. */
56
+ emptyMessage?: string
57
+ /** Render the empty state instead of children. */
58
+ isEmpty?: boolean
59
+ isLoading?: boolean
60
+ className?: string
61
+ }
62
+
63
+ /**
64
+ * The base surface every SystemHealth block renders into: a titled card with a
65
+ * left tone-accent and a status badge, so a wall of them reads as a scannable
66
+ * health board. Presentational only — the tone + contents are computed by the
67
+ * composer (or, in a tenant app, by the dashboard widget that fetches the data).
68
+ */
69
+ export function HealthCard({
70
+ title,
71
+ status,
72
+ statusLabel,
73
+ icon: Icon,
74
+ action,
75
+ children,
76
+ emptyMessage = 'Nothing to report.',
77
+ isEmpty,
78
+ isLoading,
79
+ className,
80
+ }: HealthCardProps) {
81
+ return (
82
+ <div
83
+ className={cn(
84
+ 'rounded-xl border border-l-4 bg-card text-card-foreground shadow p-5 space-y-4',
85
+ HEALTH_TONE[status].border,
86
+ isLoading && 'animate-pulse',
87
+ className,
88
+ )}
89
+ >
90
+ <div className="flex items-center justify-between gap-3">
91
+ <div className="flex items-center gap-2 min-w-0">
92
+ {Icon && <Icon className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />}
93
+ <h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
94
+ </div>
95
+ <div className="flex items-center gap-2 flex-shrink-0">
96
+ {action}
97
+ <HealthBadge status={status} label={statusLabel} />
98
+ </div>
99
+ </div>
100
+
101
+ {isEmpty ? (
102
+ <p className="text-sm text-gray-500 py-4 text-center">{emptyMessage}</p>
103
+ ) : (
104
+ children
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,99 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { GitBranch } from 'lucide-react'
4
+ import { HealthCard } from './HealthCard'
5
+ import { HEALTH_TONE, worstStatus, type HealthStatus } from './health-status'
6
+
7
+ export interface PipelineStage {
8
+ label: string
9
+ count: number
10
+ /** Optional per-stage tone accent (default derived: 0 records = neutral, else ok). */
11
+ tone?: HealthStatus
12
+ }
13
+
14
+ export interface PipelineHealthProps {
15
+ title?: string
16
+ stages: PipelineStage[]
17
+ /** Records stuck / aging past their SLA — surfaces as the card's warning. */
18
+ attention?: number
19
+ attentionLabel?: string
20
+ onStageClick?: (label: string) => void
21
+ emptyMessage?: string
22
+ className?: string
23
+ }
24
+
25
+ /**
26
+ * Content-pipeline health: the count of records in each stage of a status field,
27
+ * plus a "stuck / aging" attention flag. Presentational — a tenant dashboard
28
+ * widget derives `stages` from groupByStatus over one content type's records.
29
+ */
30
+ export function PipelineHealth({
31
+ title = 'Pipeline',
32
+ stages,
33
+ attention = 0,
34
+ attentionLabel,
35
+ onStageClick,
36
+ emptyMessage = 'No records yet.',
37
+ className,
38
+ }: PipelineHealthProps) {
39
+ const total = stages.reduce((sum, s) => sum + s.count, 0)
40
+ const max = Math.max(1, ...stages.map((s) => s.count))
41
+
42
+ const status: HealthStatus =
43
+ total === 0
44
+ ? 'neutral'
45
+ : attention > 0
46
+ ? worstStatus(['warn', ...stages.map((s) => s.tone ?? 'ok')])
47
+ : 'ok'
48
+
49
+ const statusLabel =
50
+ total === 0
51
+ ? undefined
52
+ : attention > 0
53
+ ? (attentionLabel ?? `${attention} need attention`)
54
+ : `${total} total`
55
+
56
+ return (
57
+ <HealthCard
58
+ title={title}
59
+ status={status}
60
+ statusLabel={statusLabel}
61
+ icon={GitBranch}
62
+ isEmpty={total === 0}
63
+ emptyMessage={emptyMessage}
64
+ className={className}
65
+ >
66
+ <div className="space-y-2.5" role="list" aria-label={`${title} stages`}>
67
+ {stages.map((stage) => {
68
+ const tone = HEALTH_TONE[stage.tone ?? (stage.count === 0 ? 'neutral' : 'ok')]
69
+ const pct = (stage.count / max) * 100
70
+ const clickable = Boolean(onStageClick)
71
+ return (
72
+ <button
73
+ key={stage.label}
74
+ type="button"
75
+ role="listitem"
76
+ disabled={!clickable}
77
+ onClick={() => onStageClick?.(stage.label)}
78
+ className={cn(
79
+ 'w-full text-left group focus:outline-none',
80
+ clickable && 'cursor-pointer focus:ring-2 focus:ring-primary-500 rounded',
81
+ )}
82
+ >
83
+ <div className="flex items-center justify-between mb-1">
84
+ <span className="text-xs font-medium text-gray-600 capitalize">{stage.label}</span>
85
+ <span className="text-xs font-bold text-gray-900 tabular-nums">{stage.count}</span>
86
+ </div>
87
+ <div className="h-1.5 rounded-full bg-gray-100 overflow-hidden">
88
+ <div
89
+ className={cn('h-full rounded-full transition-all', tone.dot, clickable && 'group-hover:opacity-80')}
90
+ style={{ width: `${pct}%` }}
91
+ />
92
+ </div>
93
+ </button>
94
+ )
95
+ })}
96
+ </div>
97
+ </HealthCard>
98
+ )
99
+ }
@@ -0,0 +1,88 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { Rss } from 'lucide-react'
4
+ import { formatRelativeTime } from '../../utils'
5
+ import { HealthCard, HealthDot } from './HealthCard'
6
+ import { worstStatus, type HealthStatus } from './health-status'
7
+
8
+ export interface SourceFreshnessItem {
9
+ name: string
10
+ /** Last time this source delivered data. Null/undefined = never = stale. */
11
+ lastUpdated?: string | Date | null
12
+ /** Explicit status; otherwise derived from lastUpdated vs staleAfterHours. */
13
+ status?: HealthStatus
14
+ }
15
+
16
+ export interface SourceFreshnessProps {
17
+ title?: string
18
+ sources: SourceFreshnessItem[]
19
+ /** A source with no update within this window is stale (default 48h). */
20
+ staleAfterHours?: number
21
+ /** Compare against this instant (injectable for tests); defaults to Date.now(). */
22
+ now?: number
23
+ emptyMessage?: string
24
+ className?: string
25
+ }
26
+
27
+ function deriveStatus(item: SourceFreshnessItem, staleAfterHours: number, now: number): HealthStatus {
28
+ if (item.status) return item.status
29
+ if (!item.lastUpdated) return 'critical'
30
+ const ts = new Date(item.lastUpdated).getTime()
31
+ if (Number.isNaN(ts)) return 'neutral'
32
+ const ageHours = (now - ts) / 3_600_000
33
+ if (ageHours <= staleAfterHours) return 'ok'
34
+ if (ageHours <= staleAfterHours * 2) return 'warn'
35
+ return 'critical'
36
+ }
37
+
38
+ /**
39
+ * Source / ingestion freshness: which upstream feeds are fresh vs stale. A stale
40
+ * source is a bug (everything is ongoing — no one-time fetches), so any stale
41
+ * feed pulls the whole card to warn/critical. Presentational — a tenant widget
42
+ * feeds it the source records + their last-fetched markers.
43
+ */
44
+ export function SourceFreshness({
45
+ title = 'Source freshness',
46
+ sources,
47
+ staleAfterHours = 48,
48
+ now,
49
+ emptyMessage = 'No sources configured.',
50
+ className,
51
+ }: SourceFreshnessProps) {
52
+ const at = now ?? Date.now()
53
+ const rows = sources.map((s) => ({ ...s, resolved: deriveStatus(s, staleAfterHours, at) }))
54
+ const staleCount = rows.filter((r) => r.resolved === 'warn' || r.resolved === 'critical').length
55
+ const status = worstStatus(rows.map((r) => r.resolved))
56
+ const statusLabel = sources.length === 0 ? undefined : staleCount > 0 ? `${staleCount} stale` : 'All fresh'
57
+
58
+ return (
59
+ <HealthCard
60
+ title={title}
61
+ status={status}
62
+ statusLabel={statusLabel}
63
+ icon={Rss}
64
+ isEmpty={sources.length === 0}
65
+ emptyMessage={emptyMessage}
66
+ className={className}
67
+ >
68
+ <ul className="space-y-2" aria-label={`${title} sources`}>
69
+ {rows.map((row) => (
70
+ <li key={row.name} className="flex items-center justify-between gap-3 text-sm">
71
+ <span className="flex items-center gap-2 min-w-0">
72
+ <HealthDot status={row.resolved} />
73
+ <span className="text-gray-700 truncate">{row.name}</span>
74
+ </span>
75
+ <span
76
+ className={cn(
77
+ 'text-xs tabular-nums flex-shrink-0',
78
+ row.resolved === 'ok' ? 'text-gray-500' : 'text-gray-900 font-medium',
79
+ )}
80
+ >
81
+ {row.lastUpdated ? formatRelativeTime(row.lastUpdated) : 'never'}
82
+ </span>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </HealthCard>
87
+ )
88
+ }
@@ -0,0 +1,142 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import {
3
+ worstStatus,
4
+ HealthCard,
5
+ PipelineHealth,
6
+ SourceFreshness,
7
+ DeliveryHealth,
8
+ AttentionFeed,
9
+ } from '../index'
10
+
11
+ const NOW = 1_700_000_000_000 // fixed instant for freshness/delivery derivation
12
+ const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString()
13
+ const hoursAhead = (h: number) => new Date(NOW + h * 3_600_000).toISOString()
14
+
15
+ describe('worstStatus', () => {
16
+ it('returns neutral for an empty list', () => {
17
+ expect(worstStatus([])).toBe('neutral')
18
+ })
19
+ it('escalates critical > warn > ok > neutral', () => {
20
+ expect(worstStatus(['ok', 'neutral'])).toBe('ok')
21
+ expect(worstStatus(['ok', 'warn'])).toBe('warn')
22
+ expect(worstStatus(['ok', 'warn', 'critical'])).toBe('critical')
23
+ })
24
+ })
25
+
26
+ describe('HealthCard', () => {
27
+ it('renders the title and the tone badge', () => {
28
+ render(<HealthCard title="Pipeline" status="ok" />)
29
+ expect(screen.getByText('Pipeline')).toBeInTheDocument()
30
+ expect(screen.getByText('Healthy')).toBeInTheDocument()
31
+ })
32
+ it('shows the empty message when isEmpty', () => {
33
+ render(<HealthCard title="X" status="neutral" isEmpty emptyMessage="Nothing here" />)
34
+ expect(screen.getByText('Nothing here')).toBeInTheDocument()
35
+ })
36
+ it('honors a custom status label', () => {
37
+ render(<HealthCard title="X" status="warn" statusLabel="3 stale" />)
38
+ expect(screen.getByText('3 stale')).toBeInTheDocument()
39
+ })
40
+ })
41
+
42
+ describe('PipelineHealth', () => {
43
+ const stages = [
44
+ { label: 'draft', count: 4 },
45
+ { label: 'review', count: 2 },
46
+ { label: 'ready', count: 1 },
47
+ ]
48
+ it('renders every stage with its count', () => {
49
+ render(<PipelineHealth title="Briefs" stages={stages} />)
50
+ expect(screen.getByText('draft')).toBeInTheDocument()
51
+ expect(screen.getByText('4')).toBeInTheDocument()
52
+ expect(screen.getByText('7 total')).toBeInTheDocument()
53
+ })
54
+ it('flags attention items in the badge', () => {
55
+ render(<PipelineHealth stages={stages} attention={2} />)
56
+ expect(screen.getByText('2 need attention')).toBeInTheDocument()
57
+ })
58
+ it('renders the empty state when there are no records', () => {
59
+ render(<PipelineHealth stages={[{ label: 'draft', count: 0 }]} emptyMessage="No records yet." />)
60
+ expect(screen.getByText('No records yet.')).toBeInTheDocument()
61
+ })
62
+ it('calls onStageClick', () => {
63
+ const onClick = jest.fn()
64
+ render(<PipelineHealth stages={stages} onStageClick={onClick} />)
65
+ fireEvent.click(screen.getByText('review'))
66
+ expect(onClick).toHaveBeenCalledWith('review')
67
+ })
68
+ })
69
+
70
+ describe('SourceFreshness', () => {
71
+ it('reports all fresh when every source is recent', () => {
72
+ render(
73
+ <SourceFreshness
74
+ now={NOW}
75
+ staleAfterHours={48}
76
+ sources={[
77
+ { name: 'RSS A', lastUpdated: hoursAgo(2) },
78
+ { name: 'RSS B', lastUpdated: hoursAgo(10) },
79
+ ]}
80
+ />,
81
+ )
82
+ expect(screen.getByText('All fresh')).toBeInTheDocument()
83
+ })
84
+ it('counts stale sources and shows "never" for missing timestamps', () => {
85
+ render(
86
+ <SourceFreshness
87
+ now={NOW}
88
+ staleAfterHours={48}
89
+ sources={[
90
+ { name: 'RSS A', lastUpdated: hoursAgo(2) },
91
+ { name: 'RSS B', lastUpdated: hoursAgo(200) },
92
+ { name: 'RSS C', lastUpdated: null },
93
+ ]}
94
+ />,
95
+ )
96
+ expect(screen.getByText('2 stale')).toBeInTheDocument()
97
+ expect(screen.getByText('never')).toBeInTheDocument()
98
+ })
99
+ })
100
+
101
+ describe('DeliveryHealth', () => {
102
+ it('is on track when deliveries are on time', () => {
103
+ render(
104
+ <DeliveryHealth
105
+ now={NOW}
106
+ items={[{ label: 'Week 1', dueAt: hoursAgo(48), deliveredAt: hoursAgo(50) }]}
107
+ />,
108
+ )
109
+ expect(screen.getByText('On track')).toBeInTheDocument()
110
+ })
111
+ it('flags missed and late deliveries', () => {
112
+ render(
113
+ <DeliveryHealth
114
+ now={NOW}
115
+ items={[
116
+ { label: 'Week 1', dueAt: hoursAgo(10) }, // undelivered + past due => missed
117
+ { label: 'Week 2', dueAt: hoursAhead(24) }, // upcoming
118
+ ]}
119
+ />,
120
+ )
121
+ expect(screen.getByText('1 off-track')).toBeInTheDocument()
122
+ })
123
+ })
124
+
125
+ describe('AttentionFeed', () => {
126
+ it('is all clear when empty', () => {
127
+ render(<AttentionFeed items={[]} />)
128
+ expect(screen.getByText('All clear')).toBeInTheDocument()
129
+ })
130
+ it('counts items to review and links them', () => {
131
+ render(
132
+ <AttentionFeed
133
+ items={[
134
+ { id: '1', label: 'Draft needs review', href: '/t/general/1' },
135
+ { id: '2', label: 'Stuck brief' },
136
+ ]}
137
+ />,
138
+ )
139
+ expect(screen.getByText('2 to review')).toBeInTheDocument()
140
+ expect(screen.getByRole('link', { name: /Draft needs review/ })).toHaveAttribute('href', '/t/general/1')
141
+ })
142
+ })
@@ -0,0 +1,79 @@
1
+ import {
2
+ CheckCircle2,
3
+ AlertTriangle,
4
+ AlertCircle,
5
+ CircleDashed,
6
+ type LucideIcon,
7
+ } from 'lucide-react'
8
+
9
+ /**
10
+ * Shared status vocabulary for the SystemHealth blocks (768w.16.8.2).
11
+ *
12
+ * Every health signal collapses to one of four tones. The tone is always
13
+ * encoded REDUNDANTLY — a colored dot AND an icon AND a text label — so the
14
+ * signal never depends on color alone (accessibility), matching the rest of the
15
+ * @startsimpli/ui dashboard kit's static semantic palette.
16
+ */
17
+ export type HealthStatus = 'ok' | 'warn' | 'critical' | 'neutral'
18
+
19
+ export interface HealthTone {
20
+ /** Default human label for the tone (composers may override). */
21
+ label: string
22
+ /** Solid dot / bar fill. */
23
+ dot: string
24
+ /** Foreground text for the tone. */
25
+ text: string
26
+ /** Soft background for pills / accents. */
27
+ softBg: string
28
+ /** Left-accent border color for HealthCard. */
29
+ border: string
30
+ /** The lucide icon that stands in for the tone. */
31
+ Icon: LucideIcon
32
+ }
33
+
34
+ export const HEALTH_TONE: Record<HealthStatus, HealthTone> = {
35
+ ok: {
36
+ label: 'Healthy',
37
+ dot: 'bg-emerald-500',
38
+ text: 'text-emerald-700',
39
+ softBg: 'bg-emerald-50',
40
+ border: 'border-l-emerald-500',
41
+ Icon: CheckCircle2,
42
+ },
43
+ warn: {
44
+ label: 'Needs attention',
45
+ dot: 'bg-amber-500',
46
+ text: 'text-amber-700',
47
+ softBg: 'bg-amber-50',
48
+ border: 'border-l-amber-500',
49
+ Icon: AlertTriangle,
50
+ },
51
+ critical: {
52
+ label: 'Critical',
53
+ dot: 'bg-red-500',
54
+ text: 'text-red-700',
55
+ softBg: 'bg-red-50',
56
+ border: 'border-l-red-500',
57
+ Icon: AlertCircle,
58
+ },
59
+ neutral: {
60
+ label: 'No data',
61
+ dot: 'bg-gray-400',
62
+ text: 'text-gray-600',
63
+ softBg: 'bg-gray-50',
64
+ border: 'border-l-gray-300',
65
+ Icon: CircleDashed,
66
+ },
67
+ }
68
+
69
+ const SEVERITY: Record<HealthStatus, number> = { neutral: 0, ok: 1, warn: 2, critical: 3 }
70
+
71
+ /**
72
+ * Roll several signals up to the single worst tone — the accent a HealthCard
73
+ * shows. `critical` beats `warn` beats `ok`; `neutral` (no data) only wins when
74
+ * there is nothing else to report.
75
+ */
76
+ export function worstStatus(statuses: HealthStatus[]): HealthStatus {
77
+ if (statuses.length === 0) return 'neutral'
78
+ return statuses.reduce((worst, s) => (SEVERITY[s] > SEVERITY[worst] ? s : worst), 'neutral')
79
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * SystemHealth blocks (768w.16.8.2) — presentational, bring-your-own-data cards
3
+ * that bubble up the health of a data-driven tenant app: pipeline status, source
4
+ * freshness, delivery/schedule adherence, and a needs-attention queue. A tenant
5
+ * dashboard widget fetches the data and hands each block plain props; the blocks
6
+ * own the tone roll-up + layout so the app wrapper stays thin.
7
+ */
8
+ export { HealthCard, HealthDot, HealthBadge } from './HealthCard'
9
+ export type { HealthCardProps } from './HealthCard'
10
+
11
+ export { HEALTH_TONE, worstStatus } from './health-status'
12
+ export type { HealthStatus, HealthTone } from './health-status'
13
+
14
+ export { PipelineHealth } from './PipelineHealth'
15
+ export type { PipelineHealthProps, PipelineStage } from './PipelineHealth'
16
+
17
+ export { SourceFreshness } from './SourceFreshness'
18
+ export type { SourceFreshnessProps, SourceFreshnessItem } from './SourceFreshness'
19
+
20
+ export { DeliveryHealth } from './DeliveryHealth'
21
+ export type { DeliveryHealthProps, DeliveryItem, DeliveryState } from './DeliveryHealth'
22
+
23
+ export { AttentionFeed } from './AttentionFeed'
24
+ export type { AttentionFeedProps, AttentionItem } from './AttentionFeed'
@@ -53,6 +53,7 @@ export * from './unified-table/utils'
53
53
  // Export Navigation
54
54
  export * from './navigation/sidebar'
55
55
  export * from './navigation/app-shell'
56
+ export * from './navigation/grouped-nav'
56
57
 
57
58
  // Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
58
59
  export * from './workspace'
@@ -121,6 +122,9 @@ export * from './email-dialogs'
121
122
  // Dashboard components (MetricCard, PeriodSelector, SparklineTrend, DashboardGrid, DashboardSection)
122
123
  export * from './dashboard'
123
124
 
125
+ // SystemHealth blocks (HealthCard, PipelineHealth, SourceFreshness, DeliveryHealth, AttentionFeed)
126
+ export * from './health'
127
+
124
128
  // Enrichment components (QualityBadge, EnrichButton, EnrichmentProgress)
125
129
  export * from './enrichment'
126
130
 
@@ -0,0 +1,69 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { GroupedNav, type GroupedNavEntry } from '../grouped-nav'
3
+
4
+ const items: GroupedNavEntry[] = [
5
+ { href: '/', label: 'Dashboard' },
6
+ {
7
+ label: 'Content',
8
+ items: [
9
+ { href: '/t/weekly_brief', label: 'Weekly Briefs' },
10
+ { href: '/t/lead_magnet', label: 'Lead Magnets' },
11
+ { href: '/t/general', label: 'General' },
12
+ ],
13
+ },
14
+ ]
15
+
16
+ describe('GroupedNav', () => {
17
+ it('renders top-level links and grouped links', () => {
18
+ render(<GroupedNav items={items} activeHref="/" />)
19
+ expect(screen.getByText('Dashboard')).toBeInTheDocument()
20
+ expect(screen.getByText('Content')).toBeInTheDocument()
21
+ expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
22
+ expect(screen.getByText('General')).toBeInTheDocument()
23
+ })
24
+
25
+ it('marks the active link with aria-current=page', () => {
26
+ render(<GroupedNav items={items} activeHref="/t/lead_magnet" />)
27
+ expect(screen.getByRole('link', { name: 'Lead Magnets' })).toHaveAttribute('aria-current', 'page')
28
+ expect(screen.getByRole('link', { name: 'Weekly Briefs' })).not.toHaveAttribute('aria-current')
29
+ })
30
+
31
+ it('treats a child path as active for its link', () => {
32
+ render(<GroupedNav items={items} activeHref="/t/general/42" />)
33
+ expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('aria-current', 'page')
34
+ })
35
+
36
+ it('collapses and expands a group on heading click', () => {
37
+ render(<GroupedNav items={items} activeHref="/" />)
38
+ expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
39
+ fireEvent.click(screen.getByRole('button', { name: /Content/ }))
40
+ expect(screen.queryByText('Weekly Briefs')).not.toBeInTheDocument()
41
+ fireEvent.click(screen.getByRole('button', { name: /Content/ }))
42
+ expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
43
+ })
44
+
45
+ it('forces a collapsed group open when one of its children is active', () => {
46
+ const collapsed: GroupedNavEntry[] = [
47
+ { label: 'Content', defaultOpen: false, items: [{ href: '/t/general', label: 'General' }] },
48
+ ]
49
+ render(<GroupedNav items={collapsed} activeHref="/t/general" />)
50
+ // defaultOpen=false but the active child forces it visible
51
+ expect(screen.getByText('General')).toBeInTheDocument()
52
+ })
53
+
54
+ it('uses a custom renderLink (e.g. a router Link)', () => {
55
+ render(
56
+ <GroupedNav
57
+ items={items}
58
+ activeHref="/"
59
+ renderLink={({ link, active, className }) => (
60
+ <a href={link.href} className={className} data-active={active} data-router-link="1">
61
+ {link.label}
62
+ </a>
63
+ )}
64
+ />,
65
+ )
66
+ const link = screen.getByText('Weekly Briefs')
67
+ expect(link).toHaveAttribute('data-router-link', '1')
68
+ })
69
+ })
@@ -0,0 +1,192 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState } from 'react'
5
+ import { ChevronDown } from 'lucide-react'
6
+ import { cn } from '../../lib/utils'
7
+
8
+ export interface GroupedNavLink {
9
+ href: string
10
+ label: string
11
+ icon?: React.ReactNode
12
+ /** Optional trailing slot (count pill, status dot…). */
13
+ badge?: React.ReactNode
14
+ }
15
+
16
+ export interface GroupedNavGroup {
17
+ label: string
18
+ icon?: React.ReactNode
19
+ items: GroupedNavLink[]
20
+ /** Start expanded (default true). Ignored while a child is active — then forced open. */
21
+ defaultOpen?: boolean
22
+ /** Show a chevron toggle (default true). false = a static section heading. */
23
+ collapsible?: boolean
24
+ }
25
+
26
+ /** A top-level link, or a named group of links. Discriminated by `items`. */
27
+ export type GroupedNavEntry = GroupedNavLink | GroupedNavGroup
28
+
29
+ export interface GroupedNavRenderLinkArgs {
30
+ link: GroupedNavLink
31
+ active: boolean
32
+ className: string
33
+ /** The pre-composed inner content (icon + label + badge). */
34
+ content: React.ReactNode
35
+ }
36
+
37
+ export interface GroupedNavProps {
38
+ items: GroupedNavEntry[]
39
+ /** The current path; drives active state. */
40
+ activeHref?: string
41
+ /** Override active matching (default: exact, or `activeHref` under `href/`). */
42
+ isActive?: (href: string, activeHref?: string) => boolean
43
+ /**
44
+ * Render a link — pass `next/link` here in a Next app. Default renders a plain
45
+ * `<a>` so the primitive stays framework-agnostic.
46
+ */
47
+ renderLink?: (args: GroupedNavRenderLinkArgs) => React.ReactNode
48
+ className?: string
49
+ }
50
+
51
+ function isGroup(entry: GroupedNavEntry): entry is GroupedNavGroup {
52
+ return Array.isArray((entry as GroupedNavGroup).items)
53
+ }
54
+
55
+ function defaultIsActive(href: string, activeHref?: string): boolean {
56
+ if (!activeHref) return false
57
+ if (href === '/') return activeHref === '/'
58
+ return activeHref === href || activeHref.startsWith(href + '/')
59
+ }
60
+
61
+ const defaultRenderLink = ({ link, active, className, content }: GroupedNavRenderLinkArgs) => (
62
+ <a href={link.href} className={className} aria-current={active ? 'page' : undefined}>
63
+ {content}
64
+ </a>
65
+ )
66
+
67
+ function linkClasses(active: boolean, indented: boolean) {
68
+ return cn(
69
+ 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
70
+ indented && 'pl-9',
71
+ active
72
+ ? 'bg-primary-600 text-white'
73
+ : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
74
+ )
75
+ }
76
+
77
+ /**
78
+ * A grouped sidebar navigation list: a mix of top-level links and collapsible
79
+ * groups (e.g. Dashboard + a "Content" group of the three content types). Purely
80
+ * the `<nav>` list — the app shell owns the logo, user footer, and chrome. Active
81
+ * state and link rendering are injectable so it drops into any router.
82
+ */
83
+ export function GroupedNav({
84
+ items,
85
+ activeHref,
86
+ isActive = defaultIsActive,
87
+ renderLink = defaultRenderLink,
88
+ className,
89
+ }: GroupedNavProps) {
90
+ return (
91
+ <nav className={cn('space-y-1', className)} aria-label="Primary">
92
+ {items.map((entry, idx) =>
93
+ isGroup(entry) ? (
94
+ <NavGroup
95
+ key={`group-${entry.label}-${idx}`}
96
+ group={entry}
97
+ activeHref={activeHref}
98
+ isActive={isActive}
99
+ renderLink={renderLink}
100
+ />
101
+ ) : (
102
+ <NavLinkRow
103
+ key={entry.href}
104
+ link={entry}
105
+ active={isActive(entry.href, activeHref)}
106
+ indented={false}
107
+ renderLink={renderLink}
108
+ />
109
+ ),
110
+ )}
111
+ </nav>
112
+ )
113
+ }
114
+
115
+ function NavLinkRow({
116
+ link,
117
+ active,
118
+ indented,
119
+ renderLink,
120
+ }: {
121
+ link: GroupedNavLink
122
+ active: boolean
123
+ indented: boolean
124
+ renderLink: NonNullable<GroupedNavProps['renderLink']>
125
+ }) {
126
+ const content = (
127
+ <>
128
+ {link.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{link.icon}</span> : null}
129
+ <span className="flex-1 truncate">{link.label}</span>
130
+ {link.badge ? <span className="flex-shrink-0">{link.badge}</span> : null}
131
+ </>
132
+ )
133
+ return <>{renderLink({ link, active, className: linkClasses(active, indented), content })}</>
134
+ }
135
+
136
+ function NavGroup({
137
+ group,
138
+ activeHref,
139
+ isActive,
140
+ renderLink,
141
+ }: {
142
+ group: GroupedNavGroup
143
+ activeHref?: string
144
+ isActive: NonNullable<GroupedNavProps['isActive']>
145
+ renderLink: NonNullable<GroupedNavProps['renderLink']>
146
+ }) {
147
+ const collapsible = group.collapsible ?? true
148
+ const hasActiveChild = group.items.some((l) => isActive(l.href, activeHref))
149
+ const [open, setOpen] = useState(group.defaultOpen ?? true)
150
+ const expanded = hasActiveChild || open
151
+
152
+ const links = expanded
153
+ ? group.items.map((link) => (
154
+ <NavLinkRow
155
+ key={link.href}
156
+ link={link}
157
+ active={isActive(link.href, activeHref)}
158
+ indented
159
+ renderLink={renderLink}
160
+ />
161
+ ))
162
+ : null
163
+
164
+ const heading = (
165
+ <span className="flex items-center gap-2 min-w-0">
166
+ {group.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{group.icon}</span> : null}
167
+ <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider truncate">{group.label}</span>
168
+ </span>
169
+ )
170
+
171
+ return (
172
+ <div className="pt-3 first:pt-0">
173
+ {collapsible ? (
174
+ <button
175
+ type="button"
176
+ onClick={() => setOpen((o) => !o)}
177
+ aria-expanded={expanded}
178
+ className="w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
179
+ >
180
+ {heading}
181
+ <ChevronDown
182
+ className={cn('w-4 h-4 text-gray-400 transition-transform flex-shrink-0', expanded ? '' : '-rotate-90')}
183
+ aria-hidden="true"
184
+ />
185
+ </button>
186
+ ) : (
187
+ <div className="px-3 py-1.5">{heading}</div>
188
+ )}
189
+ {links && <div className="mt-1 space-y-1">{links}</div>}
190
+ </div>
191
+ )
192
+ }