@startsimpli/ui 0.4.50 → 0.4.52

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.
@@ -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
+ })