@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.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * startsim-768w.19.5.1 (RED) — the shared, filterable activity feed.
3
+ *
4
+ * The control plane's Activity tab is an app-local list: no filters, no
5
+ * grouping, no reuse, and an event type the UI has never heard of renders as
6
+ * "State changed". This suite is the spec for moving all of that into
7
+ * packages/ui as a primitive (ActivityTimeline, generalised) PLUS a page-level
8
+ * composer (ActivityFeedPanel) — rule #9.
9
+ *
10
+ * THE DESIGN GUARD, stated once: the UI makes NO visibility decisions. The
11
+ * stream endpoint already filtered by the caller's resolved permission level,
12
+ * so a row that arrives is renderable, and an action string the UI does not
13
+ * know renders with a humanised label. Registering a new event type must need
14
+ * zero UI changes.
15
+ */
16
+ import * as fs from 'node:fs'
17
+ import * as path from 'node:path'
18
+ import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
19
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
20
+
21
+ import { ActivityTimeline } from '../../ActivityTimeline'
22
+ import {
23
+ ActivityFeedPanel,
24
+ ActivityFilterBar,
25
+ activityHead,
26
+ activityLabel,
27
+ activityTone,
28
+ deriveActivityFilterOptions,
29
+ toActivityTimelineItems,
30
+ type ActivityEvent,
31
+ type ActivityFilterValue,
32
+ type ActivityPage,
33
+ } from '../index'
34
+
35
+ // --------------------------------------------------------------------------
36
+ // helpers
37
+ // --------------------------------------------------------------------------
38
+
39
+ function event(over: Partial<ActivityEvent> & { id: string }): ActivityEvent {
40
+ return {
41
+ action: 'state_changed',
42
+ type: 'state_changed',
43
+ target_type: 'state',
44
+ target_id: '',
45
+ metadata: {},
46
+ actor: null,
47
+ actor_email: null,
48
+ state: null,
49
+ previous_state: null,
50
+ source: 'orchestrator',
51
+ reason: '',
52
+ error: '',
53
+ run_id: '',
54
+ run_kind: '',
55
+ created_at: '2026-07-20T12:00:00Z',
56
+ ...over,
57
+ }
58
+ }
59
+
60
+ function page(results: ActivityEvent[], over: Partial<ActivityPage> = {}): ActivityPage {
61
+ return { count: results.length, next: null, previous: null, results, ...over }
62
+ }
63
+
64
+ function withQuery(ui: React.ReactElement) {
65
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
66
+ return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
67
+ }
68
+
69
+ /** Scope a query to the rendered rows. The filter bar derives its options from
70
+ * the same rows, so an unscoped `getByText('Member invited')` would match both
71
+ * the row and its <option>. */
72
+ function timeline() {
73
+ return within(screen.getByTestId('activity-timeline'))
74
+ }
75
+ async function findInTimeline(text: string) {
76
+ await waitFor(() => expect(timeline().getByText(text)).toBeInTheDocument())
77
+ return timeline().getByText(text)
78
+ }
79
+
80
+ /** Capture the IntersectionObserver callback so a test can "scroll" the
81
+ * sentinel into view without a layout engine. */
82
+ function stubIntersectionObserver() {
83
+ const calls: Array<() => void> = []
84
+ class IO {
85
+ constructor(private cb: IntersectionObserverCallback) {
86
+ calls.push(() => this.cb(
87
+ [{ isIntersecting: true } as IntersectionObserverEntry],
88
+ this as unknown as IntersectionObserver,
89
+ ))
90
+ }
91
+ observe() {}
92
+ unobserve() {}
93
+ disconnect() {}
94
+ takeRecords() { return [] }
95
+ root = null
96
+ rootMargin = ''
97
+ thresholds = []
98
+ }
99
+ ;(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = IO
100
+ return { intersect: () => calls.forEach((f) => f()) }
101
+ }
102
+
103
+ // --------------------------------------------------------------------------
104
+ // 2. ActivityTimeline serves BOTH shapes (generalised, not forked)
105
+ // --------------------------------------------------------------------------
106
+
107
+ describe('ActivityTimeline (generalised)', () => {
108
+ it('renders an event-stream item that has none of the CRM fields', () => {
109
+ const { container } = render(
110
+ <ActivityTimeline
111
+ activities={[
112
+ {
113
+ id: 'e1',
114
+ type: 'member_invited',
115
+ title: 'Member invited',
116
+ occurredAt: '2026-07-20T12:00:00Z',
117
+ actor: { id: 'u1', name: 'Ada Lovelace', email: 'ada@x.com' },
118
+ tone: 'ok',
119
+ detail: 'nordby@ogmc.ai',
120
+ },
121
+ ]}
122
+ />,
123
+ )
124
+ expect(screen.getByText('Member invited')).toBeInTheDocument()
125
+ // Actor identity, not a bare email string.
126
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
127
+ expect(screen.getByText('AL')).toBeInTheDocument()
128
+ expect(screen.getByText('nordby@ogmc.ai')).toBeInTheDocument()
129
+ // The tone is addressable so a host can style/assert it.
130
+ expect(container.querySelector('[data-tone="ok"]')).not.toBeNull()
131
+ })
132
+
133
+ it('still renders the CRM shape apps/market passes today', () => {
134
+ render(
135
+ <ActivityTimeline
136
+ loading={false}
137
+ activities={[
138
+ {
139
+ id: 'a1',
140
+ type: 'call',
141
+ title: 'Discovery call',
142
+ description: 'Talked pricing',
143
+ occurredAt: '2026-07-20T12:00:00Z',
144
+ createdBy: { name: 'Rep One' },
145
+ durationMinutes: 30,
146
+ outcome: 'connected',
147
+ participants: [{ name: 'Buyer' }],
148
+ },
149
+ ]}
150
+ />,
151
+ )
152
+ expect(screen.getByText('Discovery call')).toBeInTheDocument()
153
+ expect(screen.getByText('Rep One')).toBeInTheDocument()
154
+ expect(screen.getByText('30m')).toBeInTheDocument()
155
+ expect(screen.getByText('connected')).toBeInTheDocument()
156
+ })
157
+
158
+ it('groups entries under day headings when asked', () => {
159
+ render(
160
+ <ActivityTimeline
161
+ groupByDay
162
+ activities={[
163
+ { id: 'e1', type: 'x', title: 'Newer', occurredAt: '2026-07-21T12:00:00Z' },
164
+ { id: 'e2', type: 'x', title: 'Older', occurredAt: '2026-07-20T12:00:00Z' },
165
+ ]}
166
+ />,
167
+ )
168
+ const headings = screen.getAllByTestId('activity-day-heading').map((n) => n.textContent)
169
+ expect(headings).toHaveLength(2)
170
+ expect(headings[0]).toMatch(/Jul 21/)
171
+ expect(headings[1]).toMatch(/Jul 20/)
172
+ })
173
+
174
+ it('calls onLoadMore when the scroll sentinel comes into view', () => {
175
+ const io = stubIntersectionObserver()
176
+ const onLoadMore = jest.fn()
177
+ render(
178
+ <ActivityTimeline
179
+ autoLoadMore
180
+ hasMore
181
+ onLoadMore={onLoadMore}
182
+ activities={[{ id: 'e1', type: 'x', title: 'One', occurredAt: '2026-07-20T12:00:00Z' }]}
183
+ />,
184
+ )
185
+ io.intersect()
186
+ expect(onLoadMore).toHaveBeenCalled()
187
+ })
188
+ })
189
+
190
+ // --------------------------------------------------------------------------
191
+ // 5. DESIGN GUARD — an UNKNOWN action renders
192
+ // --------------------------------------------------------------------------
193
+
194
+ describe('activityLabel / activityTone (widened)', () => {
195
+ it('humanises an action the UI has never heard of instead of saying "State changed"', () => {
196
+ const e = event({ id: 'e1', action: 'pipeline_source_paused', type: 'pipeline_source_paused' })
197
+ expect(activityLabel(e)).toBe('Pipeline source paused')
198
+ expect(activityLabel(e)).not.toBe('State changed')
199
+ // Neutral, not an error, not a lie.
200
+ expect(activityTone(e)).toBe('muted')
201
+ })
202
+
203
+ it('keeps the richer lifecycle rendering', () => {
204
+ expect(activityLabel(event({ id: '1', state: 'live', previous_state: 'provisioning' }))).toBe('Live')
205
+ expect(activityLabel(event({ id: '2', state: 'draft' }))).toBe('Created')
206
+ expect(
207
+ activityLabel(event({ id: '3', action: 'lifecycle_failed', type: 'lifecycle_failed', run_kind: 'provision' })),
208
+ ).toBe('Provision failed')
209
+ expect(activityTone(event({ id: '4', state: 'live' }))).toBe('ok')
210
+ expect(activityTone(event({ id: '5', state: 'paused' }))).toBe('warn')
211
+ expect(activityTone(event({ id: '6', action: 'lifecycle_failed', type: 'lifecycle_failed' }))).toBe('error')
212
+ })
213
+
214
+ it('activityHead is the newest state transition, skipping every other class', () => {
215
+ const head = activityHead([
216
+ event({ id: 'e1', action: 'member_invited', type: 'member_invited' }),
217
+ event({ id: 'e2', state: 'live' }),
218
+ event({ id: 'e3', state: 'draft' }),
219
+ ])
220
+ expect(head?.id).toBe('e2')
221
+ })
222
+
223
+ it('maps any event to a timeline item without knowing its type', () => {
224
+ const [item] = toActivityTimelineItems([
225
+ event({
226
+ id: 'e9',
227
+ action: 'brand_new_never_seen',
228
+ type: 'brand_new_never_seen',
229
+ actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
230
+ }),
231
+ ])
232
+ expect(item.title).toBe('Brand new never seen')
233
+ expect(item.actor?.name).toBe('Ada Lovelace')
234
+ expect(item.tone).toBe('muted')
235
+ })
236
+ })
237
+
238
+ // --------------------------------------------------------------------------
239
+ // 3. ActivityFilterBar
240
+ // --------------------------------------------------------------------------
241
+
242
+ describe('ActivityFilterBar', () => {
243
+ const options = deriveActivityFilterOptions([
244
+ event({
245
+ id: 'e1',
246
+ action: 'member_invited',
247
+ type: 'member_invited',
248
+ actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
249
+ }),
250
+ event({ id: 'e2', action: 'state_changed', type: 'state_changed', state: 'live' }),
251
+ ])
252
+
253
+ it('derives its options from the rows the user actually received', () => {
254
+ expect(options.actions.map((o) => o.value).sort()).toEqual(['member_invited', 'state_changed'])
255
+ expect(options.actions.find((o) => o.value === 'member_invited')?.label).toBe('Member invited')
256
+ expect(options.actors).toEqual([{ value: 'u1', label: 'Ada Lovelace' }])
257
+ })
258
+
259
+ it('emits the whole next value on every control', () => {
260
+ const onChange = jest.fn()
261
+ render(<ActivityFilterBar value={{}} onChange={onChange} {...options} />)
262
+
263
+ fireEvent.change(screen.getByLabelText('Filter by person'), { target: { value: 'u1' } })
264
+ expect(onChange).toHaveBeenLastCalledWith({ actor: 'u1' })
265
+
266
+ fireEvent.change(screen.getByLabelText('Filter by event'), { target: { value: 'member_invited' } })
267
+ expect(onChange).toHaveBeenLastCalledWith({ action: 'member_invited' })
268
+
269
+ fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-07-20' } })
270
+ expect(onChange).toHaveBeenLastCalledWith({ since: '2026-07-20T00:00:00.000Z' })
271
+
272
+ fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-07-21' } })
273
+ expect(onChange).toHaveBeenLastCalledWith({ until: '2026-07-21T23:59:59.999Z' })
274
+
275
+ fireEvent.change(screen.getByLabelText('Search activity'), { target: { value: 'invite' } })
276
+ expect(onChange).toHaveBeenLastCalledWith({ q: 'invite' })
277
+ })
278
+
279
+ it('renders a removable chip per active filter, plus clear-all', () => {
280
+ const value: ActivityFilterValue = {
281
+ actor: 'u1',
282
+ action: 'member_invited',
283
+ since: '2026-07-20T00:00:00.000Z',
284
+ }
285
+ const onChange = jest.fn()
286
+ render(<ActivityFilterBar value={value} onChange={onChange} {...options} />)
287
+
288
+ const chips = within(screen.getByTestId('activity-active-filters'))
289
+ expect(chips.getByText(/Person: Ada Lovelace/)).toBeInTheDocument()
290
+ expect(chips.getByText(/Event: Member invited/)).toBeInTheDocument()
291
+ expect(chips.getByText(/From Jul 20, 2026/)).toBeInTheDocument()
292
+
293
+ fireEvent.click(screen.getByLabelText('Remove filter: person'))
294
+ expect(onChange).toHaveBeenLastCalledWith({ action: 'member_invited', since: '2026-07-20T00:00:00.000Z' })
295
+
296
+ fireEvent.click(screen.getByRole('button', { name: /clear all/i }))
297
+ expect(onChange).toHaveBeenLastCalledWith({})
298
+ })
299
+
300
+ it('shows no chips and no clear-all when nothing is filtered', () => {
301
+ render(<ActivityFilterBar value={{}} onChange={jest.fn()} {...options} />)
302
+ expect(screen.queryByRole('button', { name: /clear all/i })).toBeNull()
303
+ })
304
+ })
305
+
306
+ // --------------------------------------------------------------------------
307
+ // 4. ActivityFeedPanel — THE page composer
308
+ // --------------------------------------------------------------------------
309
+
310
+ describe('ActivityFeedPanel', () => {
311
+ const rows = [
312
+ event({
313
+ id: 'e1',
314
+ action: 'member_invited',
315
+ type: 'member_invited',
316
+ actor: { id: 'u1', email: 'ada@x.com', name: 'Ada Lovelace' },
317
+ created_at: '2026-07-21T12:00:00Z',
318
+ }),
319
+ event({ id: 'e2', state: 'live', previous_state: 'provisioning', created_at: '2026-07-20T12:00:00Z' }),
320
+ ]
321
+
322
+ it('calls the injected client and renders the rows under day headings', async () => {
323
+ const list = jest.fn().mockResolvedValue(page(rows))
324
+ withQuery(<ActivityFeedPanel client={{ list }} />)
325
+
326
+ await findInTimeline('Member invited')
327
+ expect(timeline().getByText('Live')).toBeInTheDocument()
328
+ expect(screen.getAllByTestId('activity-day-heading')).toHaveLength(2)
329
+ expect(list).toHaveBeenCalledWith(expect.objectContaining({ page: 1 }))
330
+ })
331
+
332
+ it('re-queries the server when a filter changes (never client-filters)', async () => {
333
+ const list = jest.fn().mockResolvedValue(page(rows))
334
+ withQuery(<ActivityFeedPanel client={{ list }} />)
335
+ await findInTimeline('Member invited')
336
+
337
+ fireEvent.change(screen.getByLabelText('Filter by event'), { target: { value: 'member_invited' } })
338
+
339
+ await waitFor(() =>
340
+ expect(list).toHaveBeenCalledWith(expect.objectContaining({ action: 'member_invited', page: 1 })),
341
+ )
342
+ })
343
+
344
+ it('pages the real paginated endpoint through onLoadMore', async () => {
345
+ const io = stubIntersectionObserver()
346
+ const list = jest
347
+ .fn()
348
+ .mockResolvedValueOnce(page([rows[0]], { count: 2, next: 'http://x/?page=2' }))
349
+ .mockResolvedValueOnce(page([rows[1]], { count: 2, previous: 'http://x/?page=1' }))
350
+ withQuery(<ActivityFeedPanel client={{ list }} />)
351
+ await findInTimeline('Member invited')
352
+
353
+ io.intersect()
354
+
355
+ await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ page: 2 })))
356
+ await findInTimeline('Live')
357
+ })
358
+
359
+ it('separates empty, filtered-empty and error into three distinct states', async () => {
360
+ const empty = jest.fn().mockResolvedValue(page([]))
361
+ const { unmount } = withQuery(<ActivityFeedPanel client={{ list: empty }} />)
362
+ expect(await screen.findByText(/nothing recorded yet/i)).toBeInTheDocument()
363
+ unmount()
364
+
365
+ const filtered = jest.fn().mockResolvedValue(page([]))
366
+ const { unmount: u2 } = withQuery(<ActivityFeedPanel client={{ list: filtered }} />)
367
+ await screen.findByText(/nothing recorded yet/i)
368
+ fireEvent.change(screen.getByLabelText('Search activity'), { target: { value: 'zzz' } })
369
+ expect(await screen.findByText(/no activity matches these filters/i)).toBeInTheDocument()
370
+ u2()
371
+
372
+ const boom = jest.fn().mockRejectedValue(new Error('boom'))
373
+ withQuery(<ActivityFeedPanel client={{ list: boom }} />)
374
+ expect(await screen.findByRole('alert')).toHaveTextContent(/boom|could not load/i)
375
+ })
376
+
377
+ it('renders every action it receives — there is no allowlist', async () => {
378
+ const list = jest.fn().mockResolvedValue(
379
+ page([
380
+ event({ id: 'a', action: 'schema_type_created', type: 'schema_type_created' }),
381
+ event({ id: 'b', action: 'user_logged_in', type: 'user_logged_in' }),
382
+ event({ id: 'c', action: 'wildly_new_thing', type: 'wildly_new_thing' }),
383
+ ]),
384
+ )
385
+ withQuery(<ActivityFeedPanel client={{ list }} />)
386
+ await findInTimeline('Schema type created')
387
+ expect(timeline().getByText('User logged in')).toBeInTheDocument()
388
+ expect(timeline().getByText('Wildly new thing')).toBeInTheDocument()
389
+ })
390
+
391
+ it('hands the loaded rows to the host footer (the reconcile invariant surface)', async () => {
392
+ const list = jest.fn().mockResolvedValue(page(rows))
393
+ withQuery(
394
+ <ActivityFeedPanel
395
+ client={{ list }}
396
+ renderFooter={(events) => <span>head:{activityHead(events)?.state ?? 'none'}</span>}
397
+ />,
398
+ )
399
+ expect(await screen.findByText('head:live')).toBeInTheDocument()
400
+ })
401
+ })
402
+
403
+ // --------------------------------------------------------------------------
404
+ // 6. DESIGN GUARD — no client-side visibility logic anywhere in this layer
405
+ // --------------------------------------------------------------------------
406
+
407
+ describe('design guard: the UI decides nothing about visibility', () => {
408
+ it('has no role check, no permission branch and no action allowlist in source', () => {
409
+ const dir = path.resolve(__dirname, '..')
410
+ const sources = fs
411
+ .readdirSync(dir)
412
+ .filter((f) => /\.tsx?$/.test(f))
413
+ .map((f) => ({ f, src: fs.readFileSync(path.join(dir, f), 'utf8') }))
414
+
415
+ expect(sources.length).toBeGreaterThan(0)
416
+ const banned = [
417
+ /is_org_admin/,
418
+ /isOrgAdmin/,
419
+ /hasPermission/,
420
+ /canManage/,
421
+ /accessLevel/,
422
+ /resolve_level/,
423
+ /\brole\s*===/,
424
+ /VISIBLE_ACTIONS/,
425
+ /ALLOWED_ACTIONS/,
426
+ /ACTIVITY_ACTIONS/,
427
+ ]
428
+ for (const { f, src } of sources) {
429
+ for (const re of banned) {
430
+ expect(`${f}:${re.source}:${re.test(src)}`).toBe(`${f}:${re.source}:false`)
431
+ }
432
+ }
433
+ })
434
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Rendering an event stream WITHOUT knowing which event types exist.
3
+ *
4
+ * This is the whole point of startsim-768w.19.5: the backend registers a new
5
+ * event type as a constant plus a permission codename, and the UI must not have
6
+ * to change. So there is no vocabulary here — the lifecycle rows get a richer
7
+ * rendering because they carry extra fields, and everything else is derived
8
+ * from the action string's own shape. An action nobody has ever seen renders as
9
+ * a humanised sentence with a neutral tone; it never falls through to a wrong
10
+ * label like "State changed", and it is never dropped.
11
+ */
12
+ import type { ActivityTimelineItem, ActivityTone } from '../ActivityTimeline';
13
+ import type {
14
+ ActivityEvent,
15
+ ActivityFilterOption,
16
+ ActivityFilterOptions,
17
+ } from './types';
18
+
19
+ /** Marker dot classes by tone — for hosts rendering their own compact list. */
20
+ export const TONE_DOT: Record<ActivityTone, string> = {
21
+ ok: 'bg-emerald-500',
22
+ warn: 'bg-amber-500',
23
+ error: 'bg-red-500',
24
+ muted: 'bg-muted-foreground/40',
25
+ };
26
+
27
+ /** Where a status event came from, in words. `orchestrator` is the ordinary
28
+ * case and needs no badge; the others explain a state the operator did not
29
+ * fire themselves (startsim-bq7s.1). */
30
+ export const SOURCE_NOTE: Record<string, string> = {
31
+ reconcile: 'detected by drift reconcile',
32
+ out_of_band: 'changed outside the control plane',
33
+ backfill: 'recorded when status tracking shipped',
34
+ };
35
+
36
+ const STATE_LABELS: Record<string, string> = {
37
+ draft: 'Draft',
38
+ provisioning: 'Provisioning',
39
+ live: 'Live',
40
+ paused: 'Paused',
41
+ archived: 'Archived',
42
+ deleted: 'Deleted',
43
+ };
44
+
45
+ /** Sentence-case a raw action: `member_invited` -> `Member invited`. */
46
+ export function humanizeAction(action: string): string {
47
+ const words = String(action ?? '').replace(/[_.]+/g, ' ').trim();
48
+ return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : 'Event';
49
+ }
50
+
51
+ /** The raw action of an event, whichever name the stream uses for it. */
52
+ export function actionOf(event: ActivityEvent): string {
53
+ return (event.action || event.type || '').trim();
54
+ }
55
+
56
+ /** Human label for one row: the state a transition produced, the run that
57
+ * failed, or — for every other type, known or not — the action itself. */
58
+ export function activityLabel(event: ActivityEvent): string {
59
+ const action = actionOf(event);
60
+
61
+ if (action === 'lifecycle_failed') {
62
+ const kind = (event.run_kind ?? '').replace(/_/g, ' ').trim();
63
+ return kind ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)} failed` : 'Run failed';
64
+ }
65
+
66
+ if (action === 'state_changed') {
67
+ if (!event.state) return 'State changed';
68
+ // The very first transition is the foundry coming into existence, not a
69
+ // move "to draft" — say so.
70
+ if (event.state === 'draft' && !event.previous_state) return 'Created';
71
+ return STATE_LABELS[event.state] ?? event.state;
72
+ }
73
+
74
+ return humanizeAction(action);
75
+ }
76
+
77
+ /**
78
+ * Severity of a row, for the marker.
79
+ *
80
+ * Derived from MORPHOLOGY, not from a list of known actions: a lifecycle row
81
+ * uses the state it produced, and anything else is read off the verb at the end
82
+ * of the action string. An action whose verb is unrecognised is `muted` — the
83
+ * honest answer, and the reason a new event type needs no change here.
84
+ */
85
+ export function activityTone(event: ActivityEvent): ActivityTone {
86
+ const action = actionOf(event);
87
+
88
+ if (/(^|_)(failed|failure|error|errored)$/.test(action)) return 'error';
89
+
90
+ switch (event.state) {
91
+ case 'live':
92
+ return 'ok';
93
+ case 'paused':
94
+ case 'archived':
95
+ return 'warn';
96
+ case 'deleted':
97
+ return 'error';
98
+ default:
99
+ break;
100
+ }
101
+
102
+ if (/_(removed|deleted|revoked|expired|denied|rejected)$/.test(action)) return 'warn';
103
+ if (/_(created|added|invited|activated|succeeded|completed|deployed)$/.test(action)) return 'ok';
104
+ return 'muted';
105
+ }
106
+
107
+ /** The newest state transition in the stream — the entry that must agree with
108
+ * the state badge. Every other class is skipped: a failed run, a member invite
109
+ * or a branding edit does not define a state. */
110
+ export function activityHead(
111
+ events: ActivityEvent[] | undefined,
112
+ ): ActivityEvent | undefined {
113
+ return (events ?? []).find((e) => actionOf(e) === 'state_changed' && !!e.state);
114
+ }
115
+
116
+ /** The body line for a row: the failure message, else the stated reason, else
117
+ * what was touched. */
118
+ function detailOf(event: ActivityEvent): string {
119
+ if (event.error) return event.error;
120
+ if (event.reason) return event.reason;
121
+ const target = [event.target_type, event.target_id].filter(Boolean).join(' · ');
122
+ return actionOf(event) === 'state_changed' ? '' : target;
123
+ }
124
+
125
+ /** Map any stream row onto the shared timeline's item shape. Nothing in here
126
+ * branches on a known type. */
127
+ export function toActivityTimelineItems(
128
+ events: ActivityEvent[] | undefined,
129
+ ): ActivityTimelineItem[] {
130
+ return (events ?? []).map((event) => {
131
+ const tone = activityTone(event);
132
+ const actor = event.actor ?? (event.actor_email ? { email: event.actor_email } : undefined);
133
+ const detail = detailOf(event);
134
+ return {
135
+ id: event.id,
136
+ type: actionOf(event),
137
+ title: activityLabel(event),
138
+ occurredAt: event.created_at,
139
+ actor: actor ?? undefined,
140
+ tone,
141
+ subtitle: event.previous_state && event.state ? `from ${event.previous_state}` : undefined,
142
+ note: event.source ? SOURCE_NOTE[event.source] : undefined,
143
+ detail: detail || undefined,
144
+ detailTone: event.error ? 'error' : 'muted',
145
+ };
146
+ });
147
+ }
148
+
149
+ function byLabel(a: ActivityFilterOption, b: ActivityFilterOption): number {
150
+ return a.label.localeCompare(b.label);
151
+ }
152
+
153
+ /**
154
+ * Filter options derived from the rows the user ACTUALLY RECEIVED — never from
155
+ * a hardcoded vocabulary. Offering a facet the caller's level can never return
156
+ * would leak the existence of events they may not see; deriving it cannot.
157
+ */
158
+ export function deriveActivityFilterOptions(
159
+ events: ActivityEvent[] | undefined,
160
+ ): ActivityFilterOptions {
161
+ const actors = new Map<string, string>();
162
+ const actions = new Map<string, string>();
163
+
164
+ for (const event of events ?? []) {
165
+ const action = actionOf(event);
166
+ if (action && !actions.has(action)) actions.set(action, humanizeAction(action));
167
+
168
+ const actor = event.actor;
169
+ const value = actor?.id || actor?.email || event.actor_email || '';
170
+ if (!value) continue;
171
+ const label = (actor?.name || actor?.email || event.actor_email || value) as string;
172
+ if (!actors.has(value)) actors.set(value, label);
173
+ }
174
+
175
+ return {
176
+ actors: [...actors].map(([value, label]) => ({ value, label })).sort(byLabel),
177
+ actions: [...actions].map(([value, label]) => ({ value, label })).sort(byLabel),
178
+ };
179
+ }
@@ -0,0 +1,31 @@
1
+ /** Default Tailwind classes for {@link ActivityFeedPanel}. startsim-768w.19.5.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
3
+ import type { ActivityFilterBarClassNames } from './activity-filter-bar-default-class-names'
4
+
5
+ export interface ActivityFeedPanelClassNames {
6
+ root?: string
7
+ header?: string
8
+ heading?: string
9
+ title?: string
10
+ description?: string
11
+ filters?: string
12
+ body?: string
13
+ footer?: string
14
+ countLine?: string
15
+ /** Forwarded so an app still overrides one map. */
16
+ filterBar?: ActivityFilterBarClassNames
17
+ }
18
+
19
+ export const ACTIVITY_FEED_PANEL_DEFAULTS: Required<
20
+ Omit<ActivityFeedPanelClassNames, 'filterBar'>
21
+ > = {
22
+ root: 'rounded-lg border border-border bg-card',
23
+ header: 'flex flex-wrap items-start justify-between gap-2 border-b border-border px-5 py-3',
24
+ heading: 'space-y-1',
25
+ title: 'font-medium text-foreground',
26
+ description: 'text-xs text-muted-foreground',
27
+ filters: 'border-b border-border px-5 py-3',
28
+ body: 'max-h-[32rem] overflow-y-auto px-5 py-4',
29
+ footer: 'border-t border-border px-5 py-3 text-sm',
30
+ countLine: 'border-t border-border px-5 py-2 text-xs text-muted-foreground',
31
+ }
@@ -0,0 +1,34 @@
1
+ /** Default Tailwind classes for {@link ActivityFilterBar}. startsim-768w.19.5.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
3
+
4
+ export interface ActivityFilterBarClassNames {
5
+ root?: string
6
+ controls?: string
7
+ search?: string
8
+ select?: string
9
+ date?: string
10
+ dateGroup?: string
11
+ dateSeparator?: string
12
+ chips?: string
13
+ chip?: string
14
+ chipRemove?: string
15
+ clearAll?: string
16
+ }
17
+
18
+ export const ACTIVITY_FILTER_BAR_DEFAULTS: Required<ActivityFilterBarClassNames> = {
19
+ root: 'space-y-2',
20
+ controls: 'flex flex-wrap items-center gap-2',
21
+ search:
22
+ 'h-8 min-w-[10rem] flex-1 rounded-md border border-input bg-background px-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring',
23
+ select:
24
+ 'h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring',
25
+ dateGroup: 'flex items-center gap-1',
26
+ date:
27
+ 'h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring',
28
+ dateSeparator: 'text-xs text-muted-foreground',
29
+ chips: 'flex flex-wrap items-center gap-1.5',
30
+ chip:
31
+ 'inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground',
32
+ chipRemove: 'rounded-full px-0.5 text-muted-foreground transition-colors hover:text-foreground',
33
+ clearAll: 'text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline',
34
+ }