@startsimpli/ui 0.4.77 → 0.4.79

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.77",
3
+ "version": "0.4.79",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -17,6 +17,7 @@
17
17
  "./theme/contract": "./theme/contract.css",
18
18
  "./blog": "./src/components/blog/index.ts",
19
19
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
20
+ "./activity": "./src/components/activity/index.ts",
20
21
  "./collection": "./src/components/collection/index.ts",
21
22
  "./document-editor": "./src/components/document-editor/index.ts",
22
23
  "./email-editor": "./src/components/email-editor/index.ts",
@@ -43,7 +44,7 @@
43
44
  "react": "^18.0.0 || ^19.0.0",
44
45
  "react-dom": "^18.0.0 || ^19.0.0",
45
46
  "@startsimpli/auth": "^0.4.31",
46
- "@startsimpli/api": "^0.5.46"
47
+ "@startsimpli/api": "^0.5.48"
47
48
  },
48
49
  "peerDependenciesMeta": {
49
50
  "@startsimpli/api": {
@@ -1,17 +1,57 @@
1
1
  'use client';
2
2
 
3
- import { ComponentType } from 'react';
3
+ /**
4
+ * ActivityTimeline — ONE timeline for every "what happened" list in the
5
+ * monorepo (startsim-768w.19.5).
6
+ *
7
+ * It started CRM-shaped (a logged call with a duration and an outcome) and is
8
+ * now generalised rather than forked: every CRM field is optional, and an
9
+ * append-only EVENT STREAM row — an actor, a tone, a free-form detail — renders
10
+ * through the same component. Nothing here knows which event types exist; the
11
+ * caller hands it a title and a tone, so registering a new event type upstream
12
+ * needs no change in this file.
13
+ */
14
+
15
+ import { ComponentType, ReactNode, useEffect, useMemo, useRef } from 'react';
4
16
  import { formatDistanceToNow } from 'date-fns';
5
17
  import { Clock, User } from 'lucide-react';
6
18
  import { EmptyState } from './states';
7
19
 
20
+ /** Severity of an entry, for the marker. Presentation only — never a gate. */
21
+ export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
22
+
23
+ /** Who did it. Rendered as an initials chip so a feed shows a person rather
24
+ * than a bare email string. */
25
+ export interface ActivityTimelineActor {
26
+ id?: string;
27
+ name?: string | null;
28
+ email?: string | null;
29
+ avatarUrl?: string | null;
30
+ }
31
+
8
32
  export interface ActivityTimelineItem {
9
33
  id: string;
10
34
  type: string;
11
35
  title: string;
12
36
  description?: string;
13
37
  occurredAt: string;
14
- createdBy: { name: string };
38
+
39
+ // --- event-stream shape -------------------------------------------------
40
+ /** Identity of the person behind the event (preferred over `createdBy`). */
41
+ actor?: ActivityTimelineActor;
42
+ /** Colours the marker; `activityColors[type]` still wins when supplied. */
43
+ tone?: ActivityTone;
44
+ /** Muted inline qualifier after the title, e.g. "from provisioning". */
45
+ subtitle?: ReactNode;
46
+ /** Small pill next to the title, e.g. "detected by drift reconcile". */
47
+ note?: ReactNode;
48
+ /** Body line under the title — a reason, an error, a target. */
49
+ detail?: ReactNode;
50
+ /** Colours `detail` (an error reads red, a reason reads muted). */
51
+ detailTone?: ActivityTone;
52
+
53
+ // --- CRM shape (apps/market) — all optional ------------------------------
54
+ createdBy?: { name: string };
15
55
  durationMinutes?: number;
16
56
  outcome?: string;
17
57
  isAutomated?: boolean;
@@ -20,16 +60,39 @@ export interface ActivityTimelineItem {
20
60
 
21
61
  export interface ActivityTimelineProps {
22
62
  activities: ActivityTimelineItem[];
23
- loading: boolean;
63
+ loading?: boolean;
24
64
  emptyTitle?: string;
25
65
  emptyDescription?: string;
26
66
  onLoadMore?: () => void;
27
67
  hasMore?: boolean;
68
+ /** Fire `onLoadMore` when a sentinel below the last row scrolls into view. */
69
+ autoLoadMore?: boolean;
70
+ /** Bucket entries under day headings (Today / Yesterday / Mon d, yyyy). */
71
+ groupByDay?: boolean;
28
72
  activityIcons?: Record<string, ComponentType<any>>;
29
73
  activityColors?: Record<string, string>;
74
+ /** Icon for a type with no entry in `activityIcons` — including types this
75
+ * build has never heard of. */
76
+ defaultIcon?: ComponentType<any>;
30
77
  formatTime?: (date: string) => string;
78
+ loadMoreLabel?: string;
79
+ className?: string;
31
80
  }
32
81
 
82
+ const TONE_MARKER: Record<ActivityTone, string> = {
83
+ ok: 'bg-emerald-500/15 text-emerald-600',
84
+ warn: 'bg-amber-500/15 text-amber-600',
85
+ error: 'bg-red-500/15 text-red-600',
86
+ muted: 'bg-muted text-muted-foreground',
87
+ };
88
+
89
+ const TONE_TEXT: Record<ActivityTone, string> = {
90
+ ok: 'text-emerald-600',
91
+ warn: 'text-amber-600',
92
+ error: 'text-red-600',
93
+ muted: 'text-muted-foreground',
94
+ };
95
+
33
96
  function defaultFormatTime(date: string): string {
34
97
  try {
35
98
  return formatDistanceToNow(new Date(date), { addSuffix: true });
@@ -38,26 +101,213 @@ function defaultFormatTime(date: string): string {
38
101
  }
39
102
  }
40
103
 
104
+ /** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
105
+ export function actorInitials(actor?: ActivityTimelineActor): string {
106
+ const name = (actor?.name ?? '').trim();
107
+ if (name) {
108
+ return name
109
+ .split(/\s+/)
110
+ .slice(0, 2)
111
+ .map((w) => w[0]?.toUpperCase() ?? '')
112
+ .join('');
113
+ }
114
+ const email = (actor?.email ?? '').trim();
115
+ return email ? email[0]!.toUpperCase() : '';
116
+ }
117
+
118
+ function actorName(actor?: ActivityTimelineActor): string {
119
+ return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
120
+ }
121
+
122
+ function dayKey(iso: string): string {
123
+ const d = new Date(iso);
124
+ return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
125
+ }
126
+
127
+ /** Day heading. Relative for the two days an operator is actually looking at,
128
+ * absolute beyond that. */
129
+ export function dayHeading(iso: string): string {
130
+ const d = new Date(iso);
131
+ if (Number.isNaN(d.getTime())) return 'Unknown date';
132
+ const today = new Date();
133
+ if (d.toDateString() === today.toDateString()) return 'Today';
134
+ const yesterday = new Date(today);
135
+ yesterday.setDate(yesterday.getDate() - 1);
136
+ if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
137
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
138
+ }
139
+
140
+ /** Fires `onVisible` when it scrolls into view. No-ops where there is no
141
+ * IntersectionObserver (SSR, older jsdom) — the explicit button still works. */
142
+ function LoadMoreSentinel({ onVisible }: { onVisible: () => void }) {
143
+ const ref = useRef<HTMLDivElement | null>(null);
144
+ const cb = useRef(onVisible);
145
+ cb.current = onVisible;
146
+
147
+ useEffect(() => {
148
+ const node = ref.current;
149
+ if (!node || typeof IntersectionObserver === 'undefined') return;
150
+ const io = new IntersectionObserver(
151
+ (entries) => {
152
+ if (entries.some((e) => e.isIntersecting)) cb.current();
153
+ },
154
+ { rootMargin: '200px' },
155
+ );
156
+ io.observe(node);
157
+ return () => io.disconnect();
158
+ }, []);
159
+
160
+ return <div ref={ref} aria-hidden data-testid="activity-load-more-sentinel" className="h-px w-full" />;
161
+ }
162
+
163
+ function TimelineEntry({
164
+ activity,
165
+ activityIcons,
166
+ activityColors,
167
+ defaultIcon,
168
+ formatTime,
169
+ }: {
170
+ activity: ActivityTimelineItem;
171
+ activityIcons: Record<string, ComponentType<any>>;
172
+ activityColors: Record<string, string>;
173
+ defaultIcon?: ComponentType<any>;
174
+ formatTime: (date: string) => string;
175
+ }) {
176
+ const Icon = activityIcons[activity.type] ?? defaultIcon;
177
+ const tone = activity.tone;
178
+ const initials = actorInitials(activity.actor);
179
+ const markerClass =
180
+ activityColors[activity.type] ||
181
+ (tone ? TONE_MARKER[tone] : undefined) ||
182
+ 'bg-muted text-muted-foreground';
183
+ const who = actorName(activity.actor);
184
+ // With no icon the marker itself becomes the avatar, so the metadata line
185
+ // shows the name alone rather than the same initials twice.
186
+ const markerIsAvatar = !Icon && !!initials;
187
+
188
+ return (
189
+ <div className="relative flex gap-4" data-activity-id={activity.id}>
190
+ <div
191
+ data-tone={tone}
192
+ className={`relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-medium ${markerClass}`}
193
+ >
194
+ {Icon ? <Icon className="h-5 w-5" /> : initials || null}
195
+ </div>
196
+
197
+ <div className="min-w-0 flex-1 rounded-lg border border-border bg-card p-4">
198
+ <div className="mb-2 flex items-start justify-between gap-3">
199
+ <div className="min-w-0 flex-1">
200
+ <div className="flex flex-wrap items-baseline gap-x-2">
201
+ <h4 className="font-medium text-foreground">{activity.title}</h4>
202
+ {activity.subtitle && (
203
+ <span className="text-xs text-muted-foreground">{activity.subtitle}</span>
204
+ )}
205
+ {activity.note && (
206
+ <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
207
+ {activity.note}
208
+ </span>
209
+ )}
210
+ </div>
211
+ {activity.description && (
212
+ <p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
213
+ )}
214
+ {activity.detail && (
215
+ <p className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
216
+ {activity.detail}
217
+ </p>
218
+ )}
219
+ </div>
220
+ <div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
221
+ {formatTime(activity.occurredAt)}
222
+ </div>
223
+ </div>
224
+
225
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
226
+ {who && (
227
+ <span className="flex items-center gap-1.5">
228
+ {initials && !markerIsAvatar && (
229
+ <span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
230
+ {initials}
231
+ </span>
232
+ )}
233
+ {who}
234
+ </span>
235
+ )}
236
+
237
+ {!who && activity.createdBy && (
238
+ <span className="flex items-center gap-1">
239
+ <User className="h-3 w-3" />
240
+ {activity.createdBy.name}
241
+ </span>
242
+ )}
243
+
244
+ {activity.durationMinutes && (
245
+ <span className="flex items-center gap-1">
246
+ <Clock className="h-3 w-3" />
247
+ {activity.durationMinutes}m
248
+ </span>
249
+ )}
250
+
251
+ {activity.outcome && (
252
+ <span className="rounded bg-muted px-2 py-0.5 text-xs text-foreground">
253
+ {activity.outcome.replace(/_/g, ' ')}
254
+ </span>
255
+ )}
256
+
257
+ {activity.isAutomated && (
258
+ <span className="text-xs italic text-muted-foreground">Auto-tracked</span>
259
+ )}
260
+ </div>
261
+
262
+ {activity.participants && activity.participants.length > 0 && (
263
+ <div className="mt-2 border-t border-border pt-2">
264
+ <div className="text-xs text-muted-foreground">
265
+ With: {activity.participants.map((p) => p.name).filter(Boolean).join(', ')}
266
+ </div>
267
+ </div>
268
+ )}
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
41
274
  export function ActivityTimeline({
42
275
  activities,
43
- loading,
276
+ loading = false,
44
277
  emptyTitle = 'No activities yet',
45
278
  emptyDescription = 'Log calls, meetings, and notes to track your interactions',
46
279
  onLoadMore,
47
280
  hasMore = false,
281
+ autoLoadMore = false,
282
+ groupByDay = false,
48
283
  activityIcons = {},
49
284
  activityColors = {},
285
+ defaultIcon,
50
286
  formatTime = defaultFormatTime,
287
+ loadMoreLabel = 'Load more activities',
288
+ className = 'space-y-4',
51
289
  }: ActivityTimelineProps) {
290
+ const groups = useMemo(() => {
291
+ if (!groupByDay) return null;
292
+ const out: Array<{ key: string; heading: string; items: ActivityTimelineItem[] }> = [];
293
+ for (const a of activities) {
294
+ const key = dayKey(a.occurredAt);
295
+ const last = out[out.length - 1];
296
+ if (last && last.key === key) last.items.push(a);
297
+ else out.push({ key, heading: dayHeading(a.occurredAt), items: [a] });
298
+ }
299
+ return out;
300
+ }, [activities, groupByDay]);
301
+
52
302
  if (loading) {
53
303
  return (
54
- <div className="space-y-4">
304
+ <div className={className} data-testid="activity-timeline">
55
305
  {[...Array(3)].map((_, i) => (
56
- <div key={i} className="flex gap-4 animate-pulse">
57
- <div className="w-10 h-10 bg-gray-200 rounded-full" />
306
+ <div key={i} className="flex animate-pulse gap-4">
307
+ <div className="h-10 w-10 rounded-full bg-muted" />
58
308
  <div className="flex-1">
59
- <div className="h-4 bg-gray-200 rounded w-1/3 mb-2" />
60
- <div className="h-3 bg-gray-200 rounded w-2/3" />
309
+ <div className="mb-2 h-4 w-1/3 rounded bg-muted" />
310
+ <div className="h-3 w-2/3 rounded bg-muted" />
61
311
  </div>
62
312
  </div>
63
313
  ))}
@@ -65,108 +315,55 @@ export function ActivityTimeline({
65
315
  );
66
316
  }
67
317
 
318
+ const entryProps = { activityIcons, activityColors, defaultIcon, formatTime };
319
+
68
320
  return (
69
- <div className="space-y-4">
70
- {/* Timeline */}
321
+ <div className={className} data-testid="activity-timeline">
71
322
  <div className="relative">
72
- {/* Timeline line */}
73
- <div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
74
-
75
- {/* Activities */}
76
- <div className="space-y-6">
77
- {activities.length === 0 ? (
78
- <EmptyState title={emptyTitle} description={emptyDescription} />
79
- ) : (
80
- activities.map((activity) => {
81
- const Icon = activityIcons[activity.type];
82
- const colorClass =
83
- activityColors[activity.type] || 'bg-gray-100 text-gray-600';
84
-
85
- return (
86
- <div key={activity.id} className="relative flex gap-4">
87
- {/* Icon */}
88
- <div
89
- className={`relative z-10 flex items-center justify-center w-10 h-10 rounded-full ${colorClass}`}
90
- >
91
- {Icon ? <Icon className="w-5 h-5" /> : null}
92
- </div>
93
-
94
- {/* Content */}
95
- <div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
96
- <div className="flex items-start justify-between mb-2">
97
- <div className="flex-1">
98
- <h4 className="font-medium text-gray-900">
99
- {activity.title}
100
- </h4>
101
- {activity.description && (
102
- <p className="text-sm text-gray-600 mt-1">
103
- {activity.description}
104
- </p>
105
- )}
106
- </div>
107
- <div className="text-xs text-gray-500 ml-4 whitespace-nowrap">
108
- {formatTime(activity.occurredAt)}
109
- </div>
110
- </div>
111
-
112
- {/* Metadata */}
113
- <div className="flex items-center gap-4 text-sm text-gray-600">
114
- <div className="flex items-center gap-1">
115
- <User className="w-3 h-3" />
116
- {activity.createdBy.name}
117
- </div>
118
-
119
- {activity.durationMinutes && (
120
- <div className="flex items-center gap-1">
121
- <Clock className="w-3 h-3" />
122
- {activity.durationMinutes}m
123
- </div>
124
- )}
125
-
126
- {activity.outcome && (
127
- <div className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded">
128
- {activity.outcome.replace(/_/g, ' ')}
129
- </div>
130
- )}
131
-
132
- {activity.isAutomated && (
133
- <div className="text-xs text-gray-500 italic">
134
- Auto-tracked
135
- </div>
136
- )}
137
- </div>
138
-
139
- {/* Participants */}
140
- {activity.participants &&
141
- activity.participants.length > 0 && (
142
- <div className="mt-2 pt-2 border-t border-gray-100">
143
- <div className="text-xs text-gray-500">
144
- With:{' '}
145
- {activity.participants
146
- .map((p) => p.name)
147
- .filter(Boolean)
148
- .join(', ')}
149
- </div>
150
- </div>
151
- )}
152
- </div>
323
+ <div className="absolute bottom-0 left-5 top-0 w-0.5 bg-border" aria-hidden />
324
+
325
+ {activities.length === 0 ? (
326
+ <EmptyState title={emptyTitle} description={emptyDescription} />
327
+ ) : groups ? (
328
+ <div className="space-y-6">
329
+ {groups.map((g) => (
330
+ <div key={g.key} className="space-y-4">
331
+ <div
332
+ data-testid="activity-day-heading"
333
+ className="relative z-10 ml-14 text-xs font-medium uppercase tracking-wide text-muted-foreground"
334
+ >
335
+ {g.heading}
153
336
  </div>
154
- );
155
- })
156
- )}
157
- </div>
337
+ <div className="space-y-6">
338
+ {g.items.map((a) => (
339
+ <TimelineEntry key={a.id} activity={a} {...entryProps} />
340
+ ))}
341
+ </div>
342
+ </div>
343
+ ))}
344
+ </div>
345
+ ) : (
346
+ <div className="space-y-6">
347
+ {activities.map((a) => (
348
+ <TimelineEntry key={a.id} activity={a} {...entryProps} />
349
+ ))}
350
+ </div>
351
+ )}
158
352
  </div>
159
353
 
160
- {/* Load More */}
161
354
  {hasMore && onLoadMore && (
162
- <div className="text-center pt-4">
163
- <button
164
- onClick={onLoadMore}
165
- className="px-4 py-2 text-sm text-primary hover:text-primary/80 hover:bg-primary/10 rounded-md transition-colors"
166
- >
167
- Load more activities
168
- </button>
169
- </div>
355
+ <>
356
+ {autoLoadMore && <LoadMoreSentinel onVisible={onLoadMore} />}
357
+ <div className="pt-4 text-center">
358
+ <button
359
+ type="button"
360
+ onClick={onLoadMore}
361
+ className="rounded-md px-4 py-2 text-sm text-primary transition-colors hover:bg-primary/10 hover:text-primary/80"
362
+ >
363
+ {loadMoreLabel}
364
+ </button>
365
+ </div>
366
+ </>
170
367
  )}
171
368
  </div>
172
369
  );