@startsimpli/integrations 0.1.5 → 0.2.1

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,437 @@
1
+ /**
2
+ * THE TRACKING SURFACE — what came out of your meetings and is still owed
3
+ * (bd startsim-768w.38.25).
4
+ *
5
+ * `<TrackerPage api={trackerApi} scope={scope} />`, the same injected shape as
6
+ * `ConnectionsPage`, `LibraryPage` and `AskPage`. The app owns className slots
7
+ * and a wrapper; extraction happens behind `api.extract`, server-side, where the
8
+ * provider key is. Nothing in this file imports a provider.
9
+ *
10
+ * THE SCREEN ANSWERS FOUR QUESTIONS and nothing else, because those are the four
11
+ * a person actually has: what is outstanding, who owns it, what is overdue, and
12
+ * what changed since last week. Everything on it is either one of those or the
13
+ * evidence for one of those.
14
+ *
15
+ * EVERY ROW IS TRACEABLE, for exactly the reason `AskPage` enforces citations:
16
+ * an item nobody can trace back to something a person said is an item nobody
17
+ * will act on. So each row carries the meetings it came from, the verbatim quote
18
+ * from each, and a click-through to the record. A commitment with no mention
19
+ * cannot be rendered, because it cannot exist.
20
+ *
21
+ * FILTERS ARE SERVER PARAMETERS (CLAUDE.md rule 8) and the screen SAYS SO when
22
+ * the deployment's data API does not honour one — same `appliedFilters`
23
+ * mechanism the Library uses. A status filter that silently does nothing is
24
+ * worse on this screen than on any other: the whole product claim is "this is
25
+ * what is still open".
26
+ */
27
+ import * as React from 'react';
28
+
29
+ import { stripMarkdown } from '../Markdown';
30
+ import type {
31
+ Commitment,
32
+ CommitmentQuery,
33
+ CommitmentStatus,
34
+ CommitmentTrackerApi,
35
+ CorpusScope,
36
+ ExtractionResult,
37
+ } from '../../corpus/types';
38
+
39
+ export interface TrackerPageProps {
40
+ api: CommitmentTrackerApi;
41
+ scope: CorpusScope;
42
+ /** Click-through from a mention to the record, normally the Library route. */
43
+ onOpenDocument?: (documentId: string) => void;
44
+ /** Injectable clock, so "overdue" and "this week" are testable. */
45
+ now?: number;
46
+ classNames?: Partial<Record<TrackerSlot, string>>;
47
+ }
48
+
49
+ type TrackerSlot =
50
+ | 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
51
+ | 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due';
52
+
53
+ const DEFAULTS: Record<TrackerSlot, string> = {
54
+ root: 'space-y-6',
55
+ section: 'space-y-3',
56
+ heading: 'text-lg font-semibold text-gray-900',
57
+ card: 'rounded-lg border border-gray-200 p-4',
58
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
59
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
60
+ secondaryButton: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-800 disabled:opacity-50',
61
+ input: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm',
62
+ muted: 'text-sm text-gray-500',
63
+ error: 'text-sm text-red-600',
64
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
65
+ row: 'rounded-lg border border-gray-200 p-4',
66
+ due: 'text-sm font-medium text-red-700',
67
+ };
68
+
69
+ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: string }> = [
70
+ { key: 'open', label: 'Outstanding' },
71
+ { key: 'overdue', label: 'Overdue' },
72
+ { key: 'recent', label: 'Changed this week' },
73
+ { key: 'done', label: 'Closed' },
74
+ ];
75
+
76
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
77
+
78
+ export function TrackerPage({
79
+ api,
80
+ scope,
81
+ onOpenDocument,
82
+ now = Date.now(),
83
+ classNames = {},
84
+ }: TrackerPageProps) {
85
+ const cn = { ...DEFAULTS, ...classNames };
86
+
87
+ const [tab, setTab] = React.useState<'open' | 'overdue' | 'recent' | 'done'>('open');
88
+ const [owner, setOwner] = React.useState('');
89
+ const [rows, setRows] = React.useState<Commitment[]>([]);
90
+ const [applied, setApplied] = React.useState<string[]>([]);
91
+ const [loading, setLoading] = React.useState(true);
92
+ const [error, setError] = React.useState('');
93
+ const [run, setRun] = React.useState<ExtractionResult | null>(null);
94
+ const [running, setRunning] = React.useState(false);
95
+
96
+ const query = React.useMemo<CommitmentQuery>(
97
+ () => ({
98
+ // The SERVER is asked for what the tab means. What comes back is then
99
+ // narrowed again below — see `visible` — because a deployment whose API
100
+ // ignores `status` would otherwise render closed items under "Outstanding"
101
+ // and the banner alone would be doing all the work.
102
+ status: tab === 'done' ? 'done' : (['open', 'blocked'] as CommitmentStatus[]),
103
+ ...(owner.trim() ? { owner: owner.trim() } : {}),
104
+ ...(tab === 'recent' ? { changedAfter: new Date(now - WEEK_MS).toISOString() } : {}),
105
+ ...(tab === 'overdue' ? { dueBefore: new Date(now).toISOString() } : {}),
106
+ pageSize: 200,
107
+ }),
108
+ [tab, owner, now],
109
+ );
110
+
111
+ const load = React.useCallback(async () => {
112
+ setLoading(true);
113
+ setError('');
114
+ try {
115
+ const page = await api.listCommitments(query);
116
+ setRows(page.results);
117
+ setApplied(page.appliedFilters ?? []);
118
+ } catch (err) {
119
+ setRows([]);
120
+ setError(failureMessage(err));
121
+ } finally {
122
+ setLoading(false);
123
+ }
124
+ }, [api, query]);
125
+
126
+ React.useEffect(() => { void load(); }, [load]);
127
+
128
+ /**
129
+ * The tab, applied in the browser as well as sent to the server.
130
+ *
131
+ * NOT a violation of rule 8 — the query still goes out as parameters and the
132
+ * server still pages. This is the honest belt to the server's braces: until bd
133
+ * startsim-768w.38.12 teaches the tenant API `?status=`, the API returns
134
+ * everything, and a screen that showed closed items under "Outstanding" would
135
+ * be lying about the one thing it exists to say. The banner below states that
136
+ * the list is not server-filtered, so the user knows the count is over what
137
+ * this page could fetch rather than over the whole corpus.
138
+ */
139
+ const visible = React.useMemo(() => {
140
+ const byTab = rows.filter((item) => {
141
+ if (tab === 'done') return item.status === 'done' || item.status === 'dropped';
142
+ if (item.status !== 'open' && item.status !== 'blocked') return false;
143
+ if (tab === 'overdue') return isOverdue(item, now);
144
+ if (tab === 'recent') return changedSince(item, now - WEEK_MS);
145
+ return true;
146
+ });
147
+ const needle = owner.trim().toLowerCase();
148
+ const byOwner = needle
149
+ ? byTab.filter((item) => (item.ownerName ?? '').toLowerCase().includes(needle))
150
+ : byTab;
151
+ return [...byOwner].sort(compareForScreen(now));
152
+ }, [rows, tab, owner, now]);
153
+
154
+ const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
155
+ (key) => Boolean(query[key]) && !applied.includes(key),
156
+ );
157
+
158
+ return (
159
+ <div className={cn.root}>
160
+ <section className={cn.section}>
161
+ <div className="flex flex-wrap items-center gap-2">
162
+ <div className="flex gap-2" role="tablist" aria-label="What to show">
163
+ {STATUS_TABS.map((entry) => (
164
+ <button
165
+ key={entry.key}
166
+ type="button"
167
+ role="tab"
168
+ aria-selected={tab === entry.key}
169
+ className={tab === entry.key ? cn.button : cn.secondaryButton}
170
+ onClick={() => setTab(entry.key)}
171
+ >
172
+ {entry.label}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ <label className="ml-auto flex items-center gap-2">
177
+ <span className="sr-only">Filter by owner</span>
178
+ <input
179
+ className={cn.input}
180
+ value={owner}
181
+ placeholder="Owner"
182
+ onChange={(event) => setOwner(event.target.value)}
183
+ />
184
+ </label>
185
+ {api.extract ? (
186
+ <button
187
+ type="button"
188
+ className={cn.secondaryButton}
189
+ disabled={running}
190
+ onClick={async () => {
191
+ setRunning(true);
192
+ setError('');
193
+ try {
194
+ setRun(await api.extract!({ scope }));
195
+ await load();
196
+ } catch (err) {
197
+ setError(failureMessage(err));
198
+ } finally {
199
+ setRunning(false);
200
+ }
201
+ }}
202
+ >
203
+ {running ? 'Reading your meetings…' : 'Pull from meetings'}
204
+ </button>
205
+ ) : null}
206
+ </div>
207
+
208
+ {unfiltered.length ? (
209
+ // The same honesty the Library owes about search, owed here about
210
+ // status — and it matters more here, because "outstanding" is the
211
+ // claim the whole screen makes.
212
+ <p className={cn.warning} role="status">
213
+ This workspace’s data API does not filter by {unfiltered.join(', ')} yet, so this list was
214
+ narrowed in the browser over the records it could fetch. Counts are over that set, not
215
+ over the whole corpus.
216
+ </p>
217
+ ) : null}
218
+
219
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
220
+ {run ? <RunSummary result={run} classNames={cn} onOpenDocument={onOpenDocument} /> : null}
221
+ </section>
222
+
223
+ <section className={cn.section} aria-label="Commitments">
224
+ <h2 className={cn.heading}>
225
+ {loading ? 'Loading…' : `${visible.length} item${visible.length === 1 ? '' : 's'}`}
226
+ </h2>
227
+
228
+ {!loading && visible.length === 0 ? (
229
+ <p className={cn.muted}>
230
+ {rows.length === 0
231
+ ? 'Nothing has been pulled out of your meetings yet. Connect a source, then run “Pull from meetings”.'
232
+ : 'Nothing matches this view. Everything here is either closed or filtered out.'}
233
+ </p>
234
+ ) : null}
235
+
236
+ {visible.map((item) => (
237
+ <CommitmentRow
238
+ key={item.externalId}
239
+ item={item}
240
+ now={now}
241
+ classNames={cn}
242
+ onOpenDocument={onOpenDocument}
243
+ />
244
+ ))}
245
+ </section>
246
+ </div>
247
+ );
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function CommitmentRow({
253
+ item, now, classNames: cn, onOpenDocument,
254
+ }: {
255
+ item: Commitment;
256
+ now: number;
257
+ classNames: Record<TrackerSlot, string>;
258
+ onOpenDocument?: (documentId: string) => void;
259
+ }) {
260
+ const overdue = isOverdue(item, now);
261
+ const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / 86_400_000) : null;
262
+
263
+ return (
264
+ <article className={cn.row}>
265
+ <div className="flex flex-wrap items-center gap-2">
266
+ <span className={cn.badge}>{item.subtype}</span>
267
+ {item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
268
+ <h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
269
+ </div>
270
+
271
+ <div className="mt-1 flex flex-wrap items-center gap-3">
272
+ {item.ownerName ? (
273
+ <span className="text-sm text-gray-700">{item.ownerName}</span>
274
+ ) : (
275
+ // Stated, not blank. "Nobody said who" is a fact about the meeting and
276
+ // it is the single most actionable thing on this screen.
277
+ <span className={cn.muted}>No owner named</span>
278
+ )}
279
+ {item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
280
+ {item.dueAt ? (
281
+ <span className={overdue ? cn.due : cn.muted}>
282
+ {overdue ? 'Overdue ' : 'Due '}
283
+ {new Date(item.dueAt).toLocaleDateString()}
284
+ </span>
285
+ ) : null}
286
+ {age !== null && age > 0 ? (
287
+ <span className={cn.muted}>
288
+ {/* The sentence the product exists to say. */}
289
+ open for {age} day{age === 1 ? '' : 's'}
290
+ </span>
291
+ ) : null}
292
+ {item.mentions.length > 1 ? (
293
+ <span className={cn.badge}>raised in {item.mentions.length} meetings</span>
294
+ ) : null}
295
+ </div>
296
+
297
+ {item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
298
+
299
+ {item.statusReason ? (
300
+ <p className="mt-2 text-sm text-gray-600">
301
+ {/* Why does it say this is closed? Answerable without opening the
302
+ meeting, or nobody will trust a single closed row. */}
303
+ {item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
304
+ </p>
305
+ ) : null}
306
+
307
+ <details className="mt-2">
308
+ <summary className="cursor-pointer text-sm text-gray-600">
309
+ From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
310
+ </summary>
311
+ <div className="mt-2 space-y-2">
312
+ {item.mentions.map((mention) => (
313
+ <div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
314
+ <div className="flex flex-wrap items-center gap-2">
315
+ <button
316
+ type="button"
317
+ className="text-sm text-blue-700 underline"
318
+ onClick={() => onOpenDocument?.(mention.documentId)}
319
+ >
320
+ {mention.occurredAt ? new Date(mention.occurredAt).toLocaleDateString() : 'Undated meeting'}
321
+ </button>
322
+ {/* Where the row came from. A model-derived item and one the
323
+ summariser itself wrote down are different levels of
324
+ evidence, and the difference belongs on screen. */}
325
+ <span className={cn.badge}>
326
+ {mention.extractor === 'rules' ? 'from the meeting’s own next steps' : 'read from the notes'}
327
+ </span>
328
+ </div>
329
+ {mention.quote ? (
330
+ <p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(mention.quote)}”</p>
331
+ ) : null}
332
+ </div>
333
+ ))}
334
+ </div>
335
+ </details>
336
+ </article>
337
+ );
338
+ }
339
+
340
+ /**
341
+ * What the last run did, in the terms that make it arguable.
342
+ *
343
+ * `fromRules` vs `fromModel` is here on purpose: it is the measurement of what
344
+ * the model is actually contributing over the structure the summariser already
345
+ * wrote, and if that number is ever zero the model call should be switched off
346
+ * rather than paid for.
347
+ */
348
+ function RunSummary({
349
+ result, classNames: cn, onOpenDocument,
350
+ }: {
351
+ result: ExtractionResult;
352
+ classNames: Record<TrackerSlot, string>;
353
+ onOpenDocument?: (documentId: string) => void;
354
+ }) {
355
+ return (
356
+ <div className={cn.card}>
357
+ <p className="text-sm text-gray-800">
358
+ Read {result.documentsProcessed} meeting{result.documentsProcessed === 1 ? '' : 's'}:{' '}
359
+ {result.created} new, {result.merged} matched to something already tracked,{' '}
360
+ {result.resolved} closed by a later meeting.
361
+ </p>
362
+ <p className={cn.muted}>
363
+ {result.fromRules} came from the meetings’ own next-step lists, {result.fromModel} were read
364
+ out of the notes by {result.provider.live ? `${result.provider.provider} · ${result.provider.model}` : 'no live model'}.
365
+ </p>
366
+
367
+ {result.needsReview.length ? (
368
+ // Weak resolution matches are shown, never applied. A to-do list that
369
+ // quietly empties itself is the one failure with no symptom.
370
+ <div className={`mt-2 ${cn.warning}`} role="status">
371
+ <p>
372
+ {result.needsReview.length} meeting statement
373
+ {result.needsReview.length === 1 ? '' : 's'} looked like they closed something, but not
374
+ closely enough to act on. Nothing was closed automatically.
375
+ </p>
376
+ <ul className="mt-1 space-y-1">
377
+ {result.needsReview.map((entry) => (
378
+ <li key={`${entry.commitmentId}:${entry.documentId}`}>
379
+ <button
380
+ type="button"
381
+ className="text-sm underline"
382
+ onClick={() => onOpenDocument?.(entry.documentId)}
383
+ >
384
+ “{stripMarkdown(entry.quote).slice(0, 120)}”
385
+ </button>
386
+ </li>
387
+ ))}
388
+ </ul>
389
+ </div>
390
+ ) : null}
391
+
392
+ {result.droppedOutOfScope > 0 ? (
393
+ <p className={cn.error} role="status">
394
+ {result.droppedOutOfScope} record{result.droppedOutOfScope === 1 ? '' : 's'} from another
395
+ workspace were returned by the data API and excluded before extracting.
396
+ </p>
397
+ ) : null}
398
+ </div>
399
+ );
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+
404
+ function isOverdue(item: Commitment, now: number): boolean {
405
+ if (!item.dueAt) return false;
406
+ if (item.status !== 'open' && item.status !== 'blocked') return false;
407
+ const at = Date.parse(item.dueAt);
408
+ return !Number.isNaN(at) && at < now;
409
+ }
410
+
411
+ function changedSince(item: Commitment, since: number): boolean {
412
+ const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
413
+ return stamps.some((stamp) => Date.parse(stamp) >= since);
414
+ }
415
+
416
+ /**
417
+ * Overdue first, then the oldest thing still open.
418
+ *
419
+ * Sorting by "raised longest ago and still not done" rather than by recency is
420
+ * the whole difference between a list of meetings and a list of debts.
421
+ */
422
+ function compareForScreen(now: number) {
423
+ return (a: Commitment, b: Commitment): number => {
424
+ const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
425
+ if (overdue !== 0) return overdue;
426
+ if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
427
+ if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
428
+ return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
429
+ };
430
+ }
431
+
432
+ function failureMessage(err: unknown): string {
433
+ const status = (err as { status?: number })?.status;
434
+ if (status === 403) return 'You do not have access to this workspace’s records.';
435
+ if (status === 401) return 'Your session has expired. Sign in again.';
436
+ return (err as { message?: string })?.message || 'That could not be loaded right now.';
437
+ }