@startsimpli/integrations 0.2.24 → 0.2.26
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 +1 -1
- package/src/__tests__/connections.test.tsx +131 -0
- package/src/__tests__/corpus-planning.test.tsx +556 -0
- package/src/api/connections.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/ConnectionsPage.tsx +139 -15
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- package/src/components/pages/types.ts +59 -0
- package/src/components/pages/work-items.tsx +256 -0
- package/src/corpus/client.ts +368 -1
- package/src/corpus/entities.ts +63 -1
- package/src/corpus/index.ts +26 -1
- package/src/corpus/planning.ts +323 -0
- package/src/corpus/types.ts +241 -3
|
@@ -28,6 +28,14 @@ import * as React from 'react';
|
|
|
28
28
|
|
|
29
29
|
import { isStalled, runCoverage, runProgress } from '../../corpus/runs';
|
|
30
30
|
import { stripMarkdown } from '../Markdown';
|
|
31
|
+
import {
|
|
32
|
+
UnfilteredNotice,
|
|
33
|
+
WorkItemRow,
|
|
34
|
+
changedSince,
|
|
35
|
+
compareForScreen,
|
|
36
|
+
failureMessage,
|
|
37
|
+
isOverdue,
|
|
38
|
+
} from './work-items';
|
|
31
39
|
import type {
|
|
32
40
|
Commitment,
|
|
33
41
|
CommitmentQuery,
|
|
@@ -104,6 +112,9 @@ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: s
|
|
|
104
112
|
|
|
105
113
|
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
106
114
|
|
|
115
|
+
/** Which filters THIS screen asks for, and therefore owes a confession about. */
|
|
116
|
+
const TRACKER_FILTER_KEYS = ['status', 'owner', 'dueBefore', 'changedAfter'] as const;
|
|
117
|
+
|
|
107
118
|
/** States the poll stops at. A finished run is a record, not a subscription. */
|
|
108
119
|
const TERMINAL_RUN = new Set(['succeeded', 'failed', 'cancelled']);
|
|
109
120
|
|
|
@@ -330,10 +341,6 @@ export function TrackerPage({
|
|
|
330
341
|
// Rendering it as "in progress" is a spinner that never ends.
|
|
331
342
|
const sweepStalled = Boolean(sweep && isStalled(sweep, clock));
|
|
332
343
|
|
|
333
|
-
const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
|
|
334
|
-
(key) => Boolean(query[key]) && !applied.includes(key),
|
|
335
|
-
);
|
|
336
|
-
|
|
337
344
|
return (
|
|
338
345
|
<div className={cn.root}>
|
|
339
346
|
<section className={cn.section}>
|
|
@@ -418,16 +425,16 @@ export function TrackerPage({
|
|
|
418
425
|
) : null}
|
|
419
426
|
</div>
|
|
420
427
|
|
|
421
|
-
{
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
428
|
+
{/* The same honesty the Library owes about search, owed here about
|
|
429
|
+
status — and it matters more here, because "outstanding" is the
|
|
430
|
+
claim the whole screen makes. Shared with the planning surface, which
|
|
431
|
+
asks for one more key. */}
|
|
432
|
+
<UnfilteredNotice
|
|
433
|
+
requested={TRACKER_FILTER_KEYS}
|
|
434
|
+
applied={applied}
|
|
435
|
+
query={query}
|
|
436
|
+
className={cn.warning}
|
|
437
|
+
/>
|
|
431
438
|
|
|
432
439
|
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
433
440
|
{sweep ? (
|
|
@@ -450,7 +457,7 @@ export function TrackerPage({
|
|
|
450
457
|
) : null}
|
|
451
458
|
|
|
452
459
|
{visible.map((item) => (
|
|
453
|
-
<
|
|
460
|
+
<WorkItemRow
|
|
454
461
|
key={item.externalId}
|
|
455
462
|
item={item}
|
|
456
463
|
now={clock}
|
|
@@ -465,94 +472,6 @@ export function TrackerPage({
|
|
|
465
472
|
|
|
466
473
|
// ---------------------------------------------------------------------------
|
|
467
474
|
|
|
468
|
-
function CommitmentRow({
|
|
469
|
-
item, now, classNames: cn, onOpenDocument,
|
|
470
|
-
}: {
|
|
471
|
-
item: Commitment;
|
|
472
|
-
now: number;
|
|
473
|
-
classNames: Record<TrackerSlot, string>;
|
|
474
|
-
onOpenDocument?: (documentId: string) => void;
|
|
475
|
-
}) {
|
|
476
|
-
const overdue = isOverdue(item, now);
|
|
477
|
-
const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / 86_400_000) : null;
|
|
478
|
-
|
|
479
|
-
return (
|
|
480
|
-
<article className={cn.row}>
|
|
481
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
482
|
-
<span className={cn.badge}>{item.subtype}</span>
|
|
483
|
-
{item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
|
|
484
|
-
<h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
|
|
485
|
-
</div>
|
|
486
|
-
|
|
487
|
-
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
488
|
-
{item.ownerName ? (
|
|
489
|
-
<span className="text-sm text-gray-700">{item.ownerName}</span>
|
|
490
|
-
) : (
|
|
491
|
-
// Stated, not blank. "Nobody said who" is a fact about the meeting and
|
|
492
|
-
// it is the single most actionable thing on this screen.
|
|
493
|
-
<span className={cn.muted}>No owner named</span>
|
|
494
|
-
)}
|
|
495
|
-
{item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
|
|
496
|
-
{item.dueAt ? (
|
|
497
|
-
<span className={overdue ? cn.due : cn.muted}>
|
|
498
|
-
{overdue ? 'Overdue ' : 'Due '}
|
|
499
|
-
{new Date(item.dueAt).toLocaleDateString()}
|
|
500
|
-
</span>
|
|
501
|
-
) : null}
|
|
502
|
-
{age !== null && age > 0 ? (
|
|
503
|
-
<span className={cn.muted}>
|
|
504
|
-
{/* The sentence the product exists to say. */}
|
|
505
|
-
open for {age} day{age === 1 ? '' : 's'}
|
|
506
|
-
</span>
|
|
507
|
-
) : null}
|
|
508
|
-
{item.mentions.length > 1 ? (
|
|
509
|
-
<span className={cn.badge}>raised in {item.mentions.length} meetings</span>
|
|
510
|
-
) : null}
|
|
511
|
-
</div>
|
|
512
|
-
|
|
513
|
-
{item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
|
|
514
|
-
|
|
515
|
-
{item.statusReason ? (
|
|
516
|
-
<p className="mt-2 text-sm text-gray-600">
|
|
517
|
-
{/* Why does it say this is closed? Answerable without opening the
|
|
518
|
-
meeting, or nobody will trust a single closed row. */}
|
|
519
|
-
{item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
|
|
520
|
-
</p>
|
|
521
|
-
) : null}
|
|
522
|
-
|
|
523
|
-
<details className="mt-2">
|
|
524
|
-
<summary className="cursor-pointer text-sm text-gray-600">
|
|
525
|
-
From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
|
|
526
|
-
</summary>
|
|
527
|
-
<div className="mt-2 space-y-2">
|
|
528
|
-
{item.mentions.map((mention) => (
|
|
529
|
-
<div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
|
|
530
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
531
|
-
<button
|
|
532
|
-
type="button"
|
|
533
|
-
className="text-sm text-blue-700 underline"
|
|
534
|
-
onClick={() => onOpenDocument?.(mention.documentId)}
|
|
535
|
-
>
|
|
536
|
-
{mention.occurredAt ? new Date(mention.occurredAt).toLocaleDateString() : 'Undated meeting'}
|
|
537
|
-
</button>
|
|
538
|
-
{/* Where the row came from. A model-derived item and one the
|
|
539
|
-
summariser itself wrote down are different levels of
|
|
540
|
-
evidence, and the difference belongs on screen. */}
|
|
541
|
-
<span className={cn.badge}>
|
|
542
|
-
{mention.extractor === 'rules' ? 'from the meeting’s own next steps' : 'read from the notes'}
|
|
543
|
-
</span>
|
|
544
|
-
</div>
|
|
545
|
-
{mention.quote ? (
|
|
546
|
-
<p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(mention.quote)}”</p>
|
|
547
|
-
) : null}
|
|
548
|
-
</div>
|
|
549
|
-
))}
|
|
550
|
-
</div>
|
|
551
|
-
</details>
|
|
552
|
-
</article>
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
475
|
/**
|
|
557
476
|
* A SWEEP IN FLIGHT, and afterwards what it did (bd startsim-768w.38.28).
|
|
558
477
|
*
|
|
@@ -785,37 +704,6 @@ function RunSummary({
|
|
|
785
704
|
|
|
786
705
|
// ---------------------------------------------------------------------------
|
|
787
706
|
|
|
788
|
-
function isOverdue(item: Commitment, now: number): boolean {
|
|
789
|
-
if (!item.dueAt) return false;
|
|
790
|
-
if (item.status !== 'open' && item.status !== 'blocked') return false;
|
|
791
|
-
const at = Date.parse(item.dueAt);
|
|
792
|
-
return !Number.isNaN(at) && at < now;
|
|
793
|
-
}
|
|
794
707
|
|
|
795
|
-
function changedSince(item: Commitment, since: number): boolean {
|
|
796
|
-
const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
|
|
797
|
-
return stamps.some((stamp) => Date.parse(stamp) >= since);
|
|
798
|
-
}
|
|
799
708
|
|
|
800
|
-
/**
|
|
801
|
-
* Overdue first, then the oldest thing still open.
|
|
802
|
-
*
|
|
803
|
-
* Sorting by "raised longest ago and still not done" rather than by recency is
|
|
804
|
-
* the whole difference between a list of meetings and a list of debts.
|
|
805
|
-
*/
|
|
806
|
-
function compareForScreen(now: number) {
|
|
807
|
-
return (a: Commitment, b: Commitment): number => {
|
|
808
|
-
const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
|
|
809
|
-
if (overdue !== 0) return overdue;
|
|
810
|
-
if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
|
|
811
|
-
if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
|
|
812
|
-
return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
709
|
|
|
816
|
-
function failureMessage(err: unknown): string {
|
|
817
|
-
const status = (err as { status?: number })?.status;
|
|
818
|
-
if (status === 403) return 'You do not have access to this workspace’s records.';
|
|
819
|
-
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
820
|
-
return (err as { message?: string })?.message || 'That could not be loaded right now.';
|
|
821
|
-
}
|
|
@@ -47,6 +47,33 @@ export interface ConnectorSchema {
|
|
|
47
47
|
|
|
48
48
|
export type ConnectorSchemaLike = ConnectorSchema | ConnectorFieldSpec[] | null | undefined;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What `startOAuth`/`oauthInitiate` hand back (bd startsim-768w.22.25.22).
|
|
52
|
+
*
|
|
53
|
+
* `authorization_url` is COMPLETE — endpoint, `client_id`, `response_type`,
|
|
54
|
+
* `redirect_uri`, `state`, and whatever else the connector's own
|
|
55
|
+
* `authorization_params()` decided the vendor needs (Dropbox's includes
|
|
56
|
+
* `token_access_type=offline`, which is what makes the grant durable). The
|
|
57
|
+
* client opens it and adds nothing: it has no `client_id` of its own and must
|
|
58
|
+
* not have one to choose.
|
|
59
|
+
*
|
|
60
|
+
* `state` is opaque and signed. It is echoed by the vendor to the callback and
|
|
61
|
+
* is the only thing tying the redirect back to a connection; nothing in the
|
|
62
|
+
* browser needs to read it.
|
|
63
|
+
*/
|
|
64
|
+
export interface OAuthStart {
|
|
65
|
+
authorization_url: string;
|
|
66
|
+
state: string;
|
|
67
|
+
redirect_uri: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Vendor-side setup steps, written by the connector in Python. */
|
|
71
|
+
export interface ConnectionInstructions {
|
|
72
|
+
steps?: string[];
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export interface ConnectorCatalogEntry {
|
|
51
78
|
key: string;
|
|
52
79
|
name: string;
|
|
@@ -285,6 +312,22 @@ export interface ConnectionsApi {
|
|
|
285
312
|
setMemberConnectorPolicy?(slug: string, connectorKey: string, allowed: boolean):
|
|
286
313
|
Promise<{ connector_key: string; member_connect_allowed: boolean; changed: boolean }>;
|
|
287
314
|
|
|
315
|
+
/**
|
|
316
|
+
* START a consent round trip on an OAuth connector (bd startsim-768w.22.25.23).
|
|
317
|
+
*
|
|
318
|
+
* Optional so an app pinned to an older `@startsimpli/api` keeps compiling.
|
|
319
|
+
* The card branches on `auth_mode` AND on this verb being present, so a stale
|
|
320
|
+
* client renders the connector as not connectable from this screen rather
|
|
321
|
+
* than a button that cannot fire.
|
|
322
|
+
*/
|
|
323
|
+
startOAuth?(slug: string, input: {
|
|
324
|
+
connector_key: string;
|
|
325
|
+
name?: string;
|
|
326
|
+
config?: Record<string, unknown>;
|
|
327
|
+
capabilities_enabled?: string[];
|
|
328
|
+
return_to?: string;
|
|
329
|
+
}): Promise<OAuthStart>;
|
|
330
|
+
|
|
288
331
|
/** The roster: every member-owned connection in the foundry. Metadata only. */
|
|
289
332
|
memberConnections?(slug: string): Promise<{
|
|
290
333
|
results: MemberConnectionRow[];
|
|
@@ -357,4 +400,20 @@ export interface MemberConnectionsApi {
|
|
|
357
400
|
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
|
|
358
401
|
disconnect(id: string): Promise<void>;
|
|
359
402
|
connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
|
|
403
|
+
/**
|
|
404
|
+
* The OAuth half of the surface (bd startsim-768w.22.25.14/.21). Optional so
|
|
405
|
+
* an app pinned to an older `@startsimpli/api` still type-checks; the card
|
|
406
|
+
* branches on `auth_mode` and renders the hand-off only when both the mode
|
|
407
|
+
* says oauth AND the verb is present, so a stale client degrades to saying
|
|
408
|
+
* the connector cannot be connected here rather than to a dead button.
|
|
409
|
+
*/
|
|
410
|
+
startOAuth?(input: {
|
|
411
|
+
connector_key: string;
|
|
412
|
+
name?: string;
|
|
413
|
+
config?: Record<string, unknown>;
|
|
414
|
+
capabilities_enabled?: string[];
|
|
415
|
+
return_to?: string;
|
|
416
|
+
}): Promise<OAuthStart>;
|
|
417
|
+
oauthInitiate?(id: string, input?: { return_to?: string }): Promise<OAuthStart>;
|
|
418
|
+
connectionInstructions?(id: string): Promise<ConnectionInstructions>;
|
|
360
419
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PIECES TWO SCREENS SHARE — the item row, the honesty banner, and the
|
|
3
|
+
* handful of predicates that decide what "overdue" and "changed" mean
|
|
4
|
+
* (bd startsim-8l6ef).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS FILE EXISTS. `TrackerPage` and `PlanningPage` are two views over ONE
|
|
7
|
+
* list of items: the tracker asks "what came out of my meetings and is still
|
|
8
|
+
* owed", planning asks "what are we trying to achieve and what is under it".
|
|
9
|
+
* Both render the same row and both owe the same confession when the server did
|
|
10
|
+
* not honour a filter. Writing that twice is the two-lists failure moved up a
|
|
11
|
+
* level — the rows would agree while the screens slowly stopped agreeing about
|
|
12
|
+
* what they meant (CLAUDE.md rule 9, and
|
|
13
|
+
* feedback_share_page_composers_not_just_primitives).
|
|
14
|
+
*
|
|
15
|
+
* So the row and the banner live here, the extraction-run machinery stays in
|
|
16
|
+
* `TrackerPage` where it belongs, and the focus tree stays in `PlanningPage`.
|
|
17
|
+
*
|
|
18
|
+
* NOTHING HERE FETCHES. Every component takes what it renders.
|
|
19
|
+
*/
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
|
|
22
|
+
import { isAuthored } from '../../corpus/planning';
|
|
23
|
+
import { stripMarkdown } from '../Markdown';
|
|
24
|
+
import type { Commitment } from '../../corpus/types';
|
|
25
|
+
|
|
26
|
+
export type WorkItemSlot =
|
|
27
|
+
| 'badge' | 'muted' | 'row' | 'due' | 'warning';
|
|
28
|
+
|
|
29
|
+
export const WORK_ITEM_DEFAULTS: Record<WorkItemSlot, string> = {
|
|
30
|
+
badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
|
|
31
|
+
muted: 'text-sm text-gray-500',
|
|
32
|
+
row: 'rounded-lg border border-gray-200 p-4',
|
|
33
|
+
due: 'text-sm font-medium text-red-700',
|
|
34
|
+
warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86_400_000;
|
|
38
|
+
|
|
39
|
+
/** `WorkItem.Priority` — 0 critical .. 4 backlog, ascending is urgent. */
|
|
40
|
+
export const PRIORITY_LABELS = ['Critical', 'High', 'Medium', 'Low', 'Backlog'] as const;
|
|
41
|
+
|
|
42
|
+
export function priorityLabel(priority?: number): string | null {
|
|
43
|
+
if (priority === undefined || priority === null) return null;
|
|
44
|
+
return PRIORITY_LABELS[priority] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// predicates — what the screens mean by "overdue" and "changed"
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Past its stated date and still owed.
|
|
53
|
+
*
|
|
54
|
+
* A `done` or `dropped` item is never overdue however old its date is, because
|
|
55
|
+
* an overdue count is a list of things to do and a finished item is not one.
|
|
56
|
+
*/
|
|
57
|
+
export function isOverdue(item: Commitment, now: number): boolean {
|
|
58
|
+
if (!item.dueAt) return false;
|
|
59
|
+
if (item.status === 'done' || item.status === 'dropped') return false;
|
|
60
|
+
return Date.parse(item.dueAt) < now;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function changedSince(item: Commitment, since: number): boolean {
|
|
64
|
+
const stamps = [item.lastSeenAt, item.resolvedAt].filter(Boolean) as string[];
|
|
65
|
+
return stamps.some((stamp) => Date.parse(stamp) >= since);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Overdue first, then the oldest thing still open.
|
|
70
|
+
*
|
|
71
|
+
* Sorting by "raised longest ago and still not done" rather than by recency is
|
|
72
|
+
* the whole difference between a list of meetings and a list of debts.
|
|
73
|
+
*/
|
|
74
|
+
export function compareForScreen(now: number) {
|
|
75
|
+
return (a: Commitment, b: Commitment): number => {
|
|
76
|
+
const overdue = Number(isOverdue(b, now)) - Number(isOverdue(a, now));
|
|
77
|
+
if (overdue !== 0) return overdue;
|
|
78
|
+
if (a.dueAt && b.dueAt && a.dueAt !== b.dueAt) return a.dueAt.localeCompare(b.dueAt);
|
|
79
|
+
if (a.dueAt !== b.dueAt) return a.dueAt ? -1 : 1;
|
|
80
|
+
return (a.firstSeenAt ?? '').localeCompare(b.firstSeenAt ?? '');
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function failureMessage(err: unknown): string {
|
|
85
|
+
const status = (err as { status?: number })?.status;
|
|
86
|
+
if (status === 403) return 'You do not have access to this workspace’s records.';
|
|
87
|
+
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
88
|
+
return (err as { message?: string })?.message || 'That could not be loaded right now.';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// the honesty banner
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* "This workspace's data API did not filter by X."
|
|
97
|
+
*
|
|
98
|
+
* THE REQUESTED KEYS ARE A PARAMETER, and that is the change from the constant
|
|
99
|
+
* the tracker used to carry. Planning asks for `focus` as well, and a hardcoded
|
|
100
|
+
* list would have quietly excluded it from the confession — the failure being
|
|
101
|
+
* silence about silence.
|
|
102
|
+
*
|
|
103
|
+
* Note what this does NOT do: it never decides on its own whether a filter was
|
|
104
|
+
* honoured. `applied` is the SERVER's report, reconciled back into the
|
|
105
|
+
* composer's vocabulary by the client (a `focus` request comes back as
|
|
106
|
+
* `related_to`). A banner that guessed would eventually announce a gap that had
|
|
107
|
+
* been closed, which is exactly what shipped once already.
|
|
108
|
+
*/
|
|
109
|
+
export function UnfilteredNotice<K extends string>({
|
|
110
|
+
requested, applied, query, className,
|
|
111
|
+
}: {
|
|
112
|
+
requested: readonly K[];
|
|
113
|
+
applied: readonly string[];
|
|
114
|
+
query: Partial<Record<K, unknown>>;
|
|
115
|
+
className: string;
|
|
116
|
+
}) {
|
|
117
|
+
const unfiltered = requested.filter((key) => Boolean(query[key]) && !applied.includes(key));
|
|
118
|
+
if (!unfiltered.length) return null;
|
|
119
|
+
return (
|
|
120
|
+
<p className={className} role="status">
|
|
121
|
+
This workspace’s data API did not filter by {unfiltered.join(', ')}, so this list was narrowed
|
|
122
|
+
in the browser over the records it could fetch. Counts are over that set, not over the whole
|
|
123
|
+
corpus.
|
|
124
|
+
</p>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// the row
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One item, wherever it is shown.
|
|
134
|
+
*
|
|
135
|
+
* THE AUTHORED-ITEM BRANCH IS THE POINT OF THE LIFT. This row used to state
|
|
136
|
+
* unconditionally that an item came "From N meetings" and then list them — true
|
|
137
|
+
* of everything on the screen, back when the only way to get a row was
|
|
138
|
+
* extraction. Now a person can type one, and an authored item has no mentions:
|
|
139
|
+
* rendered by the old row it read "From 0 meetings" above an empty disclosure,
|
|
140
|
+
* which says the meeting record is missing rather than that there never was one.
|
|
141
|
+
*
|
|
142
|
+
* The provenance line is therefore a fork, not a count, and both arms say
|
|
143
|
+
* something true. That difference IS the placement argument made visible: an
|
|
144
|
+
* extracted item and an authored one live in one list, and the list can still
|
|
145
|
+
* tell you which is which.
|
|
146
|
+
*/
|
|
147
|
+
export function WorkItemRow({
|
|
148
|
+
item, now, classNames: cn, onOpenDocument, actions,
|
|
149
|
+
}: {
|
|
150
|
+
item: Commitment;
|
|
151
|
+
now: number;
|
|
152
|
+
classNames: Record<WorkItemSlot, string>;
|
|
153
|
+
onOpenDocument?: (documentId: string) => void;
|
|
154
|
+
/** Per-row controls the host screen owns (file, edit, complete). */
|
|
155
|
+
actions?: React.ReactNode;
|
|
156
|
+
}) {
|
|
157
|
+
const overdue = isOverdue(item, now);
|
|
158
|
+
const age = item.firstSeenAt ? Math.floor((now - Date.parse(item.firstSeenAt)) / DAY_MS) : null;
|
|
159
|
+
const authored = isAuthored(item);
|
|
160
|
+
const priority = priorityLabel(item.priority);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<article className={cn.row}>
|
|
164
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
165
|
+
<span className={cn.badge}>{item.subtype}</span>
|
|
166
|
+
{item.status !== 'open' ? <span className={cn.badge}>{item.status}</span> : null}
|
|
167
|
+
{/* Only when set. An unset priority means nobody has triaged this, and
|
|
168
|
+
printing "Medium" would turn a missing judgement into a stated one. */}
|
|
169
|
+
{priority ? <span className={cn.badge}>{priority}</span> : null}
|
|
170
|
+
<h3 className="text-sm font-medium text-gray-900">{item.title}</h3>
|
|
171
|
+
{actions ? <span className="ml-auto flex items-center gap-2">{actions}</span> : null}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
175
|
+
{item.ownerName ? (
|
|
176
|
+
<span className="text-sm text-gray-700">{item.ownerName}</span>
|
|
177
|
+
) : (
|
|
178
|
+
// Stated, not blank. "Nobody said who" is a fact about the meeting and
|
|
179
|
+
// it is the single most actionable thing on this screen.
|
|
180
|
+
<span className={cn.muted}>No owner named</span>
|
|
181
|
+
)}
|
|
182
|
+
{item.subject ? <span className={cn.muted}>· {item.subject}</span> : null}
|
|
183
|
+
{item.dueAt ? (
|
|
184
|
+
<span className={overdue ? cn.due : cn.muted}>
|
|
185
|
+
{overdue ? 'Overdue ' : 'Due '}
|
|
186
|
+
{new Date(item.dueAt).toLocaleDateString()}
|
|
187
|
+
</span>
|
|
188
|
+
) : null}
|
|
189
|
+
{age !== null && age > 0 ? (
|
|
190
|
+
<span className={cn.muted}>
|
|
191
|
+
{/* The sentence the product exists to say. */}
|
|
192
|
+
open for {age} day{age === 1 ? '' : 's'}
|
|
193
|
+
</span>
|
|
194
|
+
) : null}
|
|
195
|
+
{item.mentions.length > 1 ? (
|
|
196
|
+
<span className={cn.badge}>raised in {item.mentions.length} meetings</span>
|
|
197
|
+
) : null}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{item.detail ? <p className="mt-2 text-sm text-gray-700">{item.detail}</p> : null}
|
|
201
|
+
|
|
202
|
+
{item.statusReason ? (
|
|
203
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
204
|
+
{/* Why does it say this is closed? Answerable without opening the
|
|
205
|
+
meeting, or nobody will trust a single closed row. */}
|
|
206
|
+
{item.status === 'done' ? 'Closed by' : 'Blocked by'}: “{stripMarkdown(item.statusReason)}”
|
|
207
|
+
</p>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
{item.mentions.length === 0 ? (
|
|
211
|
+
// An authored item is its own evidence: somebody typed it, on purpose,
|
|
212
|
+
// and there is no meeting to cite. Said in one line rather than through
|
|
213
|
+
// an empty disclosure that looks like a load that failed.
|
|
214
|
+
<p className={`mt-2 ${cn.muted}`}>
|
|
215
|
+
{authored ? 'Added by hand' : 'No meeting recorded for this item'}
|
|
216
|
+
</p>
|
|
217
|
+
) : (
|
|
218
|
+
<details className="mt-2">
|
|
219
|
+
<summary className="cursor-pointer text-sm text-gray-600">
|
|
220
|
+
From {item.mentions.length} meeting{item.mentions.length === 1 ? '' : 's'}
|
|
221
|
+
</summary>
|
|
222
|
+
<div className="mt-2 space-y-2">
|
|
223
|
+
{item.mentions.map((mention) => (
|
|
224
|
+
<div key={`${mention.documentId}:${mention.originKey}`} className="border-t pt-2">
|
|
225
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="text-sm text-blue-700 underline"
|
|
229
|
+
onClick={() => onOpenDocument?.(mention.documentId)}
|
|
230
|
+
>
|
|
231
|
+
{mention.occurredAt
|
|
232
|
+
? new Date(mention.occurredAt).toLocaleDateString()
|
|
233
|
+
: 'Undated meeting'}
|
|
234
|
+
</button>
|
|
235
|
+
{/* Where the row came from. A model-derived item and one the
|
|
236
|
+
summariser itself wrote down are different levels of
|
|
237
|
+
evidence, and the difference belongs on screen. */}
|
|
238
|
+
<span className={cn.badge}>
|
|
239
|
+
{mention.extractor === 'rules'
|
|
240
|
+
? 'from the meeting’s own next steps'
|
|
241
|
+
: 'read from the notes'}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
{mention.quote ? (
|
|
245
|
+
<p className="mt-1 text-sm italic text-gray-600">
|
|
246
|
+
“{stripMarkdown(mention.quote)}”
|
|
247
|
+
</p>
|
|
248
|
+
) : null}
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
</details>
|
|
253
|
+
)}
|
|
254
|
+
</article>
|
|
255
|
+
);
|
|
256
|
+
}
|