@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.
- package/package.json +2 -1
- package/src/catalog/catalog.ts +55 -0
- package/src/components/document-editor/DocumentEditor.tsx +476 -0
- package/src/components/document-editor/__tests__/DocumentEditor.test.tsx +125 -0
- package/src/components/document-editor/__tests__/sections.test.ts +107 -0
- package/src/components/document-editor/index.ts +20 -0
- package/src/components/document-editor/sections.ts +170 -0
- package/src/components/document-editor/types.ts +32 -0
- package/src/components/health/AttentionFeed.tsx +95 -0
- package/src/components/health/DeliveryHealth.tsx +127 -0
- package/src/components/health/HealthCard.tsx +108 -0
- package/src/components/health/PipelineHealth.tsx +99 -0
- package/src/components/health/SourceFreshness.tsx +88 -0
- package/src/components/health/__tests__/health.test.tsx +142 -0
- package/src/components/health/health-status.ts +79 -0
- package/src/components/health/index.ts +24 -0
- package/src/components/index.ts +4 -0
- package/src/components/navigation/__tests__/grouped-nav.test.tsx +69 -0
- package/src/components/navigation/grouped-nav.tsx +192 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sectionsFromRecord,
|
|
3
|
+
recordPatchFromSections,
|
|
4
|
+
readData,
|
|
5
|
+
toCamelKey,
|
|
6
|
+
type DocAttribute,
|
|
7
|
+
} from '../sections';
|
|
8
|
+
import type { DocSection } from '../types';
|
|
9
|
+
|
|
10
|
+
const attrs: DocAttribute[] = [
|
|
11
|
+
{ name: 'blog', dataType: 'longtext', label: 'Blog post' },
|
|
12
|
+
{ name: 'linkedin', dataType: 'text' },
|
|
13
|
+
{ name: 'seo', dataType: 'json' },
|
|
14
|
+
{ name: 'sources', dataType: 'json' },
|
|
15
|
+
{ name: 'status', dataType: 'enum' },
|
|
16
|
+
{ name: 'stage', dataType: 'enum' },
|
|
17
|
+
{ name: 'score', dataType: 'number' },
|
|
18
|
+
{ name: 'created_at', dataType: 'date' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('toCamelKey / readData', () => {
|
|
22
|
+
it('camelCases snake and kebab keys', () => {
|
|
23
|
+
expect(toCamelKey('meta_description')).toBe('metaDescription');
|
|
24
|
+
expect(toCamelKey('primary-keyword')).toBe('primaryKeyword');
|
|
25
|
+
expect(toCamelKey('blog')).toBe('blog');
|
|
26
|
+
});
|
|
27
|
+
it('reads camel form first, then raw', () => {
|
|
28
|
+
expect(readData({ metaDescription: 'camel' }, 'meta_description')).toBe('camel');
|
|
29
|
+
expect(readData({ meta_description: 'raw' }, 'meta_description')).toBe('raw');
|
|
30
|
+
expect(readData(undefined, 'x')).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('sectionsFromRecord', () => {
|
|
35
|
+
const data = {
|
|
36
|
+
blog: '# Hello\n\nBody',
|
|
37
|
+
linkedin: 'A short post',
|
|
38
|
+
seo: { meta_description: 'desc', primary_keyword: 'kw' },
|
|
39
|
+
sources: [{ url: 'https://a.com' }, 'https://b.com'],
|
|
40
|
+
status: 'draft',
|
|
41
|
+
score: 5,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
it('routes data types to the right kinds and skips enum/system/unknown', () => {
|
|
45
|
+
const sections = sectionsFromRecord(attrs, data);
|
|
46
|
+
const byKey = Object.fromEntries(sections.map((s) => [s.key, s]));
|
|
47
|
+
expect(byKey.blog.kind).toBe('markdown');
|
|
48
|
+
expect(byKey.linkedin.kind).toBe('text');
|
|
49
|
+
expect(byKey.seo.kind).toBe('structured');
|
|
50
|
+
expect(byKey.sources.kind).toBe('list');
|
|
51
|
+
// enum (status, stage), number (score), date/system (created_at) are skipped
|
|
52
|
+
expect(sections.map((s) => s.key).sort()).toEqual(['blog', 'linkedin', 'seo', 'sources']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('coerces values into each kind shape', () => {
|
|
56
|
+
const { blog, seo, sources } = Object.fromEntries(
|
|
57
|
+
sectionsFromRecord(attrs, data).map((s) => [s.key, s]),
|
|
58
|
+
) as Record<string, DocSection>;
|
|
59
|
+
expect(typeof blog.value).toBe('string');
|
|
60
|
+
expect(seo.value).toEqual({ meta_description: 'desc', primary_keyword: 'kw' });
|
|
61
|
+
// list: array of {url} objects + a bare string → url extracted from objects, strings kept
|
|
62
|
+
expect(sources.value).toEqual(['https://a.com', 'https://b.com']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('parses a newline-delimited string into a list', () => {
|
|
66
|
+
const s = sectionsFromRecord([{ name: 'sources', dataType: 'text' }], { sources: 'https://a.com\nhttps://b.com\n' });
|
|
67
|
+
expect(s[0].kind).toBe('list');
|
|
68
|
+
expect(s[0].value).toEqual(['https://a.com', 'https://b.com']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('honors label, camelCase reads, exclude, readOnlyKeys and includeKinds', () => {
|
|
72
|
+
const camelData = { blog: 'x', linkedIn: 'y' };
|
|
73
|
+
const a: DocAttribute[] = [
|
|
74
|
+
{ name: 'blog', dataType: 'longtext', label: 'Blog post' },
|
|
75
|
+
{ name: 'linked_in', dataType: 'text' },
|
|
76
|
+
];
|
|
77
|
+
const sections = sectionsFromRecord(a, camelData, { readOnlyKeys: ['blog'] });
|
|
78
|
+
expect(sections.find((s) => s.key === 'blog')!.label).toBe('Blog post');
|
|
79
|
+
expect(sections.find((s) => s.key === 'blog')!.readOnly).toBe(true);
|
|
80
|
+
// linked_in humanized + camelCase-aware read of `linkedIn`
|
|
81
|
+
expect(sections.find((s) => s.key === 'linked_in')!.label).toBe('Linked in');
|
|
82
|
+
expect(sections.find((s) => s.key === 'linked_in')!.value).toBe('y');
|
|
83
|
+
|
|
84
|
+
expect(sectionsFromRecord(attrs, data, { exclude: ['seo'] }).some((s) => s.key === 'seo')).toBe(false);
|
|
85
|
+
expect(
|
|
86
|
+
sectionsFromRecord(attrs, data, { includeKinds: ['markdown'] }).map((s) => s.kind),
|
|
87
|
+
).toEqual(['markdown']);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('recordPatchFromSections', () => {
|
|
92
|
+
it('produces a data patch keyed by section.key with normalized shapes', () => {
|
|
93
|
+
const sections: DocSection[] = [
|
|
94
|
+
{ key: 'blog', label: 'Blog', kind: 'markdown', value: '# hi' },
|
|
95
|
+
{ key: 'linkedin', label: 'LinkedIn', kind: 'text', value: 'post' },
|
|
96
|
+
{ key: 'seo', label: 'SEO', kind: 'structured', value: { meta_description: 'd', primary_keyword: 'k' } },
|
|
97
|
+
{ key: 'sources', label: 'Sources', kind: 'list', value: ['https://a.com', ''] },
|
|
98
|
+
];
|
|
99
|
+
const patch = recordPatchFromSections(sections);
|
|
100
|
+
expect(patch).toEqual({
|
|
101
|
+
blog: '# hi',
|
|
102
|
+
linkedin: 'post',
|
|
103
|
+
seo: { meta_description: 'd', primary_keyword: 'k' },
|
|
104
|
+
sources: ['https://a.com'],
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/document-editor — a generic, schema-driven, Google-Docs-like
|
|
3
|
+
* editor over typed document sub-sections (blog markdown + text + structured SEO
|
|
4
|
+
* + source lists). Presentational + controlled: bring your own persistence.
|
|
5
|
+
*
|
|
6
|
+
* Exposed as its own subpath (like ./blog and ./collection) because it pulls
|
|
7
|
+
* react-markdown for the markdown preview — kept out of the main components
|
|
8
|
+
* barrel so consumers that don't edit documents don't drag that dependency.
|
|
9
|
+
*/
|
|
10
|
+
export { DocumentEditor } from './DocumentEditor';
|
|
11
|
+
export type { DocumentEditorProps, SaveStatus } from './DocumentEditor';
|
|
12
|
+
export type { DocSection, DocSectionKind } from './types';
|
|
13
|
+
export {
|
|
14
|
+
sectionsFromRecord,
|
|
15
|
+
recordPatchFromSections,
|
|
16
|
+
readData,
|
|
17
|
+
toCamelKey,
|
|
18
|
+
type DocAttribute,
|
|
19
|
+
type SectionsFromRecordOptions,
|
|
20
|
+
} from './sections';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven mappers between an entity record and DocumentEditor sections.
|
|
3
|
+
*
|
|
4
|
+
* These make the editor generic (not OGMC-specific): an app hands the editor an
|
|
5
|
+
* entity type's attributes + its `data` blob and gets back typed
|
|
6
|
+
* {@link DocSection}s; on save it turns the edited sections back into a `data`
|
|
7
|
+
* patch to PATCH. Pure + framework-free (no React) so they're cheap to unit-test
|
|
8
|
+
* and reuse in a Node route.
|
|
9
|
+
*
|
|
10
|
+
* Blob convention mirrors `collection/data.ts`: a tenant API client camelCases
|
|
11
|
+
* response keys, so a snake_case attr `meta_description` may land as
|
|
12
|
+
* `metaDescription` — reads try the camel form first, then the raw key.
|
|
13
|
+
*/
|
|
14
|
+
import type { DocSection, DocSectionKind } from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal attribute shape the mapper needs — a structural subset of the entity
|
|
18
|
+
* schema `AttributeDef` (name + dataType, optional label/config), so callers can
|
|
19
|
+
* pass their existing attribute objects directly.
|
|
20
|
+
*/
|
|
21
|
+
export interface DocAttribute {
|
|
22
|
+
name: string;
|
|
23
|
+
/** Entity data type — `text` | `longtext` | `json` | `enum` | `number` | … */
|
|
24
|
+
dataType: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
config?: Record<string, unknown> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SectionsFromRecordOptions {
|
|
30
|
+
/** Attribute names to skip outright (in addition to the built-in system/status skips). */
|
|
31
|
+
exclude?: string[];
|
|
32
|
+
/** Mark these section keys read-only. */
|
|
33
|
+
readOnlyKeys?: string[];
|
|
34
|
+
/** Restrict output to these kinds (e.g. only `markdown` + `text`). */
|
|
35
|
+
includeKinds?: DocSectionKind[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** snake_case / kebab → camelCase (matches collection/data.ts). */
|
|
39
|
+
export function toCamelKey(name: string): string {
|
|
40
|
+
return name.replace(/[_-]+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** camelCase-aware read of a value out of a `data` blob. */
|
|
44
|
+
export function readData(data: Record<string, unknown> | undefined | null, name: string): unknown {
|
|
45
|
+
if (!data) return undefined;
|
|
46
|
+
return data[toCamelKey(name)] ?? data[name];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Attribute names that are never editable document sections. */
|
|
50
|
+
const SYSTEM_NAMES = new Set(['id', 'status', 'created_at', 'updated_at', 'createdat', 'updatedat', 'external_id']);
|
|
51
|
+
|
|
52
|
+
function humanize(name: string): string {
|
|
53
|
+
const spaced = name.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
|
|
54
|
+
return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A name like `sources`, `source_urls`, `references` → a list section. */
|
|
58
|
+
function looksLikeList(name: string): boolean {
|
|
59
|
+
return /\bsources?\b/i.test(name) || /^sources?/i.test(name) || /_sources?$/i.test(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Decide the section kind for an attribute, or `null` to skip it. */
|
|
63
|
+
function kindFor(attr: DocAttribute): DocSectionKind | null {
|
|
64
|
+
if (looksLikeList(attr.name)) return 'list';
|
|
65
|
+
switch (attr.dataType) {
|
|
66
|
+
case 'longtext':
|
|
67
|
+
return 'markdown';
|
|
68
|
+
case 'json':
|
|
69
|
+
return 'structured';
|
|
70
|
+
case 'text':
|
|
71
|
+
return 'text';
|
|
72
|
+
default:
|
|
73
|
+
// number/boolean/date/enum/etc. are not document-editable sections.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toStr(v: unknown): string {
|
|
79
|
+
return v == null ? '' : String(v);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Coerce an arbitrary stored value into the shape a `kind` expects. */
|
|
83
|
+
function coerceValue(kind: DocSectionKind, raw: unknown): unknown {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'markdown':
|
|
86
|
+
case 'text':
|
|
87
|
+
return toStr(raw);
|
|
88
|
+
case 'list':
|
|
89
|
+
if (Array.isArray(raw)) {
|
|
90
|
+
return raw
|
|
91
|
+
.map((el) => {
|
|
92
|
+
if (typeof el === 'string') return el.trim();
|
|
93
|
+
if (el && typeof el === 'object') {
|
|
94
|
+
const o = el as Record<string, unknown>;
|
|
95
|
+
return toStr(o.url ?? o.href ?? o.value ?? o.name).trim();
|
|
96
|
+
}
|
|
97
|
+
return toStr(el).trim();
|
|
98
|
+
})
|
|
99
|
+
.filter((s) => s.length > 0);
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw === 'string') {
|
|
102
|
+
return raw
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.map((s) => s.trim())
|
|
105
|
+
.filter((s) => s.length > 0);
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
case 'structured': {
|
|
109
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
110
|
+
const out: Record<string, string> = {};
|
|
111
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) out[k] = toStr(v);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map an entity type's attributes + its `data` blob → editable DocSections.
|
|
121
|
+
* Skips system/status/enum attributes; heuristically routes `longtext`→markdown,
|
|
122
|
+
* `json`→structured, `text`→text, and any `source(s)`-ish name→list.
|
|
123
|
+
*/
|
|
124
|
+
export function sectionsFromRecord(
|
|
125
|
+
attributes: DocAttribute[],
|
|
126
|
+
data: Record<string, unknown> | undefined | null,
|
|
127
|
+
opts: SectionsFromRecordOptions = {},
|
|
128
|
+
): DocSection[] {
|
|
129
|
+
const exclude = new Set((opts.exclude ?? []).map((n) => n.toLowerCase()));
|
|
130
|
+
const readOnly = new Set(opts.readOnlyKeys ?? []);
|
|
131
|
+
const sections: DocSection[] = [];
|
|
132
|
+
|
|
133
|
+
for (const attr of attributes) {
|
|
134
|
+
const lname = attr.name.toLowerCase();
|
|
135
|
+
if (exclude.has(lname) || SYSTEM_NAMES.has(lname)) continue;
|
|
136
|
+
// Enums are status/vocabulary fields, not document prose.
|
|
137
|
+
if (attr.dataType === 'enum') continue;
|
|
138
|
+
|
|
139
|
+
const kind = kindFor(attr);
|
|
140
|
+
if (!kind) continue;
|
|
141
|
+
if (opts.includeKinds && !opts.includeKinds.includes(kind)) continue;
|
|
142
|
+
|
|
143
|
+
const placeholder = attr.config && typeof attr.config.placeholder === 'string'
|
|
144
|
+
? (attr.config.placeholder as string)
|
|
145
|
+
: undefined;
|
|
146
|
+
|
|
147
|
+
sections.push({
|
|
148
|
+
key: attr.name,
|
|
149
|
+
label: attr.label || humanize(attr.name),
|
|
150
|
+
kind,
|
|
151
|
+
value: coerceValue(kind, readData(data, attr.name)),
|
|
152
|
+
placeholder,
|
|
153
|
+
readOnly: readOnly.has(attr.name) || undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return sections;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Turn edited sections back into a `data` patch object (keyed by section.key) to
|
|
162
|
+
* merge and PATCH. Values are normalized to their kind's canonical shape.
|
|
163
|
+
*/
|
|
164
|
+
export function recordPatchFromSections(sections: DocSection[]): Record<string, unknown> {
|
|
165
|
+
const patch: Record<string, unknown> = {};
|
|
166
|
+
for (const s of sections) {
|
|
167
|
+
patch[s.key] = coerceValue(s.kind, s.value);
|
|
168
|
+
}
|
|
169
|
+
return patch;
|
|
170
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section model for the generic DocumentEditor.
|
|
3
|
+
*
|
|
4
|
+
* A "document" here is a typed, multi-part content record — e.g. a marketing
|
|
5
|
+
* draft = blog markdown + LinkedIn text + SEO fields + sources. Each part is a
|
|
6
|
+
* {@link DocSection} with a `kind` that decides how it renders + edits. Kept
|
|
7
|
+
* framework-free so the pure mappers in `./sections` (and any Node/LLM caller)
|
|
8
|
+
* can import it without dragging React.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How a section renders + edits. */
|
|
12
|
+
export type DocSectionKind = 'markdown' | 'text' | 'structured' | 'list';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One editable part of a document.
|
|
16
|
+
* `value` is typed by `kind`:
|
|
17
|
+
* - `markdown` / `text` → `string`
|
|
18
|
+
* - `structured` → `Record<string, string>`
|
|
19
|
+
* - `list` → `string[]`
|
|
20
|
+
*/
|
|
21
|
+
export interface DocSection {
|
|
22
|
+
/** Stable id — usually the source attribute name (`blog`, `linkedin`, `seo`, `sources`). */
|
|
23
|
+
key: string;
|
|
24
|
+
/** Human label shown above the section. */
|
|
25
|
+
label: string;
|
|
26
|
+
kind: DocSectionKind;
|
|
27
|
+
/** The section's current value (shape depends on `kind`). */
|
|
28
|
+
value: unknown;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
/** This one section is read-only even when the editor is editable. */
|
|
31
|
+
readOnly?: boolean;
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|