@startsimpli/integrations 0.1.6 → 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.
- package/package.json +2 -2
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +85 -4
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +134 -0
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +176 -0
- package/src/corpus/index.ts +46 -0
- package/src/corpus/llm.ts +107 -1
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
|
@@ -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
|
+
}
|
package/src/corpus/client.ts
CHANGED
|
@@ -28,13 +28,18 @@
|
|
|
28
28
|
import {
|
|
29
29
|
DEFAULT_PARTICIPANT_EDGES,
|
|
30
30
|
DEFAULT_TYPE_KEYS,
|
|
31
|
+
commitmentToEntity,
|
|
31
32
|
indexParticipants,
|
|
33
|
+
toCommitment,
|
|
32
34
|
toMoneyEvent,
|
|
33
35
|
toParty,
|
|
34
36
|
toSourceDocument,
|
|
35
37
|
} from './entities';
|
|
36
38
|
import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
37
39
|
import type {
|
|
40
|
+
Commitment,
|
|
41
|
+
CommitmentApi,
|
|
42
|
+
CommitmentQuery,
|
|
38
43
|
CorpusApi,
|
|
39
44
|
CorpusPage,
|
|
40
45
|
CorpusScope,
|
|
@@ -45,9 +50,17 @@ import type {
|
|
|
45
50
|
SourceFreshness,
|
|
46
51
|
} from './types';
|
|
47
52
|
|
|
48
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The `@startsimpli/api` client surface this adapter needs, and no more.
|
|
55
|
+
*
|
|
56
|
+
* `post` is OPTIONAL: reading the corpus is the common case and a deployment
|
|
57
|
+
* that only reads should not have to supply a write method it never calls. The
|
|
58
|
+
* commitment write path checks for it and reports honestly when it is absent,
|
|
59
|
+
* rather than failing at the moment somebody presses a button.
|
|
60
|
+
*/
|
|
49
61
|
export interface CorpusHttp {
|
|
50
62
|
get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
63
|
+
post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export interface CreateCorpusApiOptions {
|
|
@@ -62,7 +75,7 @@ export interface CreateCorpusApiOptions {
|
|
|
62
75
|
* Which `DocumentQuery` keys this deployment's API really applies. Anything
|
|
63
76
|
* not listed is reported to the user as unfiltered rather than assumed.
|
|
64
77
|
*/
|
|
65
|
-
supportedFilters?: Array<keyof DocumentQuery>;
|
|
78
|
+
supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
|
|
66
79
|
/** Endpoint bases, without leading/trailing slashes (the client convention). */
|
|
67
80
|
paths?: { entities?: string; connections?: string; relationships?: string };
|
|
68
81
|
/** How old a source's last sync may be before the Library calls it stale. */
|
|
@@ -103,6 +116,9 @@ interface DrfPage<T> {
|
|
|
103
116
|
|
|
104
117
|
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
105
118
|
|
|
119
|
+
/** Rows written in parallel. Small enough not to look like a burst to the tenant. */
|
|
120
|
+
const WRITE_CONCURRENCY = 6;
|
|
121
|
+
|
|
106
122
|
function params(query: DocumentQuery, type: string) {
|
|
107
123
|
const out: Record<string, unknown> = { type, page: query.page ?? 1 };
|
|
108
124
|
if (query.pageSize) out.page_size = query.pageSize;
|
|
@@ -117,7 +133,7 @@ function params(query: DocumentQuery, type: string) {
|
|
|
117
133
|
/** Which of the filters the caller asked for are actually honoured server-side. */
|
|
118
134
|
function appliedFilters(
|
|
119
135
|
query: DocumentQuery,
|
|
120
|
-
supported: Array<
|
|
136
|
+
supported: Array<string>,
|
|
121
137
|
): string[] {
|
|
122
138
|
const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
|
|
123
139
|
(key) => Boolean(query[key]),
|
|
@@ -125,7 +141,7 @@ function appliedFilters(
|
|
|
125
141
|
return requested.filter((key) => supported.includes(key));
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
144
|
+
export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & CommitmentApi {
|
|
129
145
|
const {
|
|
130
146
|
http,
|
|
131
147
|
scope,
|
|
@@ -251,6 +267,71 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
|
251
267
|
* Dropbox has contributed zero documents — and a count derived from the
|
|
252
268
|
* corpus alone can never say it.
|
|
253
269
|
*/
|
|
270
|
+
/**
|
|
271
|
+
* The tracking surface's read (bd startsim-768w.38.25).
|
|
272
|
+
*
|
|
273
|
+
* SERVER-SIDE FILTERING, ALWAYS (CLAUDE.md rule 8) — status, owner and due
|
|
274
|
+
* window all go out as query parameters. The tenant does not honour them
|
|
275
|
+
* yet (bd startsim-768w.38.12 is the bead for that, and the filters that
|
|
276
|
+
* matter most for THIS screen are status/owner/dueBefore, not full text), so
|
|
277
|
+
* `appliedFilters` reports the gap and the composer says so on screen rather
|
|
278
|
+
* than rendering an unfiltered list under a filled-in control.
|
|
279
|
+
*/
|
|
280
|
+
async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
|
|
281
|
+
const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
|
|
282
|
+
const response = await http.get<DrfPage<TenantEntity>>(entities, {
|
|
283
|
+
params: {
|
|
284
|
+
type: types.commitment,
|
|
285
|
+
page: query.page ?? 1,
|
|
286
|
+
...(query.pageSize ? { page_size: query.pageSize } : {}),
|
|
287
|
+
...(statuses.length ? { status: statuses.join(',') } : {}),
|
|
288
|
+
...(query.subtype ? { subtype: query.subtype } : {}),
|
|
289
|
+
...(query.owner ? { owner: query.owner } : {}),
|
|
290
|
+
...(query.dueBefore ? { due_before: query.dueBefore } : {}),
|
|
291
|
+
...(query.changedAfter ? { changed_after: query.changedAfter } : {}),
|
|
292
|
+
...(query.search ? { search: query.search } : {}),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
count: response.count ?? response.results?.length ?? 0,
|
|
297
|
+
next: response.next ?? null,
|
|
298
|
+
previous: response.previous ?? null,
|
|
299
|
+
results: (response.results ?? []).map((entity) => toCommitment(entity, scope, sourceLabels)),
|
|
300
|
+
appliedFilters: (['status', 'subtype', 'owner', 'dueBefore', 'changedAfter', 'search'] as const)
|
|
301
|
+
.filter((key) => Boolean(query[key]))
|
|
302
|
+
.filter((key) => (supportedFilters as string[]).includes(key)),
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Write a run's commitments back.
|
|
308
|
+
*
|
|
309
|
+
* ONE REQUEST PER ROW, because that is what the tenant offers: its
|
|
310
|
+
* `/api/v1/entities/upsert/` takes a single record (verified on the wire —
|
|
311
|
+
* a list body is rejected with `external_id: required for upsert`). The
|
|
312
|
+
* writes are therefore fanned out with a bounded concurrency rather than
|
|
313
|
+
* looped serially, and the missing bulk endpoint is filed rather than
|
|
314
|
+
* pretended away.
|
|
315
|
+
*
|
|
316
|
+
* Idempotency is the SERVER's, not this function's: the upsert is keyed on
|
|
317
|
+
* (entity_type, external_id), so a second run rewrites rows in place and two
|
|
318
|
+
* concurrent runs converge instead of duplicating.
|
|
319
|
+
*/
|
|
320
|
+
async saveCommitments(commitments: Commitment[]): Promise<void> {
|
|
321
|
+
if (!http.post) {
|
|
322
|
+
throw new Error('This corpus client is read-only — no write method was supplied.');
|
|
323
|
+
}
|
|
324
|
+
const post = http.post.bind(http);
|
|
325
|
+
const payloads = commitments.map(commitmentToEntity);
|
|
326
|
+
for (let index = 0; index < payloads.length; index += WRITE_CONCURRENCY) {
|
|
327
|
+
await Promise.all(
|
|
328
|
+
payloads
|
|
329
|
+
.slice(index, index + WRITE_CONCURRENCY)
|
|
330
|
+
.map((payload) => post(`${entities}/upsert`, payload)),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
254
335
|
async listSources(): Promise<SourceFreshness[]> {
|
|
255
336
|
const [connectionPage, documentPage] = await Promise.all([
|
|
256
337
|
http
|