@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.
- package/package.json +2 -2
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +206 -18
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +288 -5
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +602 -186
- package/src/corpus/index.ts +57 -1
- package/src/corpus/llm.ts +134 -2
- 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
|
+
}
|