@startsimpli/integrations 0.2.25 → 0.2.27

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,691 @@
1
+ /**
2
+ * PLANNING — what this team is trying to achieve, and what is outstanding under
3
+ * each of those things (bd startsim-8l6ef).
4
+ *
5
+ * `<PlanningPage api={planningApi} scope={scope} />`, the same injected shape as
6
+ * `ConnectionsPage`, `LibraryPage`, `AskPage` and `TrackerPage`. The app owns a
7
+ * wrapper and className slots; nothing here imports a provider or fetches by
8
+ * itself.
9
+ *
10
+ * THE RELATIONSHIP TO THE TRACKER, stated because getting it wrong is the whole
11
+ * risk of adding a second screen. There is ONE list of items and two views over
12
+ * it. The tracker asks "what came out of my meetings and is still owed" and owns
13
+ * the extraction machinery. This asks "what are we trying to achieve, and what
14
+ * is under it" and owns the focus tree. The item row, the honesty banner and the
15
+ * overdue/changed predicates are SHARED — lifted into `./work-items` before this
16
+ * file existed, precisely so the two screens cannot drift into disagreeing about
17
+ * what "overdue" means.
18
+ *
19
+ * WHAT IT LETS A PERSON DO that the tracker cannot: create a focus, create an
20
+ * item, file an existing item under a focus, and set an owner from the
21
+ * workspace's REAL members. That last one is what closes the free-text-owner gap
22
+ * (bd startsim-768w.38.29) — and it is ADDITIVE, because on a real tenant most
23
+ * people named in the material are not members, so the typed name survives
24
+ * beside the resolved one.
25
+ *
26
+ * NOTHING ON THIS SCREEN WRITES `progress` FROM AN EFFECT. A focus's standing is
27
+ * read as counts and rendered; the stored `progress` field is set by a person or
28
+ * by an explicit recalculation. A render that recomputed and PATCHed would be
29
+ * the loop recorded on `TrackerPageProps.now`, which cost 917 requests in ten
30
+ * minutes on a live tenant — with a write on every pass.
31
+ */
32
+ import * as React from 'react';
33
+
34
+ import { displayProgress } from '../../corpus/planning';
35
+ import {
36
+ UnfilteredNotice,
37
+ WorkItemRow,
38
+ compareForScreen,
39
+ failureMessage,
40
+ isOverdue,
41
+ } from './work-items';
42
+ import type {
43
+ AuthoredItem,
44
+ Commitment,
45
+ CommitmentQuery,
46
+ CommitmentStatus,
47
+ CorpusScope,
48
+ Focus,
49
+ FocusDraft,
50
+ FocusStanding,
51
+ FocusStatus,
52
+ PlanningApi,
53
+ } from '../../corpus/types';
54
+
55
+ type PlanningSlot =
56
+ | 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
57
+ | 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due' | 'progressTrack' | 'progressBar';
58
+
59
+ const DEFAULTS: Record<PlanningSlot, string> = {
60
+ root: 'space-y-6',
61
+ section: 'space-y-3',
62
+ heading: 'text-lg font-semibold text-gray-900',
63
+ card: 'rounded-lg border border-gray-200 p-4',
64
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
65
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
66
+ secondaryButton: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-800 disabled:opacity-50',
67
+ input: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm',
68
+ muted: 'text-sm text-gray-500',
69
+ error: 'text-sm text-red-600',
70
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
71
+ row: 'rounded-lg border border-gray-200 p-4',
72
+ due: 'text-sm font-medium text-red-700',
73
+ progressTrack: 'h-2 w-full overflow-hidden rounded-full bg-gray-200',
74
+ progressBar: 'h-2 rounded-full bg-gray-900 transition-all',
75
+ };
76
+
77
+ /** Which filters this screen asks for, and therefore owes a confession about. */
78
+ const PLANNING_FILTER_KEYS = ['status', 'owner', 'dueBefore', 'focus'] as const;
79
+
80
+ const FOCUS_STATUS_OPTIONS: FocusStatus[] = [
81
+ 'draft', 'active', 'on_track', 'at_risk', 'off_track', 'completed', 'cancelled',
82
+ ];
83
+
84
+ /** `CommitmentSubtype` is a string on purpose, so this list is a convenience
85
+ * rather than a constraint — `requirement` costs nothing to add because of it. */
86
+ const SUBTYPE_OPTIONS = ['action', 'requirement', 'decision', 'need', 'risk', 'question'];
87
+
88
+ const PRIORITY_OPTIONS = [
89
+ { value: 0, label: 'Critical' }, { value: 1, label: 'High' },
90
+ { value: 2, label: 'Medium' }, { value: 3, label: 'Low' }, { value: 4, label: 'Backlog' },
91
+ ];
92
+
93
+ /**
94
+ * How many focus standings are fetched at once.
95
+ *
96
+ * Each one is three count-only reads, so a workspace with forty focuses is a
97
+ * hundred and twenty requests if they all go at once. Bounded rather than
98
+ * unbounded for the same reason the corpus graph read is: the tenant data API
99
+ * pays for every one of them.
100
+ */
101
+ const STANDING_CONCURRENCY = 4;
102
+
103
+ export interface PlanningPageProps {
104
+ api: PlanningApi;
105
+ scope: CorpusScope;
106
+ /** Click-through from a mention to the record, normally the Library route. */
107
+ onOpenDocument?: (documentId: string) => void;
108
+ /**
109
+ * Injectable clock, so "overdue" is testable.
110
+ *
111
+ * NOT DEFAULTED IN THE SIGNATURE, for the reason spelled out on
112
+ * `TrackerPageProps.now`: `Date.now()` in a default parameter is a new value
113
+ * on every render, which makes every memo below it new, which fires the load
114
+ * effect, which sets state, which renders.
115
+ */
116
+ now?: number;
117
+ classNames?: Partial<Record<PlanningSlot, string>>;
118
+ }
119
+
120
+ export function PlanningPage({
121
+ api, scope, onOpenDocument, now, classNames = {},
122
+ }: PlanningPageProps) {
123
+ const cn = { ...DEFAULTS, ...classNames };
124
+
125
+ // Frozen once. See the note on the prop.
126
+ const [mountedAt] = React.useState(() => Date.now());
127
+ const clock = now ?? mountedAt;
128
+
129
+ // The api is an injected CAPABILITY, not data, so its identity is not a reason
130
+ // to re-read. Held in a ref and read at call time.
131
+ const apiRef = React.useRef(api);
132
+ apiRef.current = api;
133
+
134
+ const [focuses, setFocuses] = React.useState<Focus[]>([]);
135
+ const [standings, setStandings] = React.useState<Record<string, FocusStanding>>({});
136
+ const [selected, setSelected] = React.useState<string | null>(null);
137
+ const [items, setItems] = React.useState<Commitment[]>([]);
138
+ const [applied, setApplied] = React.useState<string[]>([]);
139
+ const [members, setMembers] = React.useState<Array<{ sub: string; email: string; name?: string }>>([]);
140
+ const [showDone, setShowDone] = React.useState(false);
141
+ const [owner, setOwner] = React.useState('');
142
+ const [loadingFocuses, setLoadingFocuses] = React.useState(true);
143
+ const [loadingItems, setLoadingItems] = React.useState(false);
144
+ const [error, setError] = React.useState('');
145
+ const [epoch, setEpoch] = React.useState(0);
146
+
147
+ const canAuthor = Boolean(api.createCommitment && api.createFocus);
148
+
149
+ // -- focuses -------------------------------------------------------------
150
+
151
+ const loadFocuses = React.useCallback(async () => {
152
+ setLoadingFocuses(true);
153
+ setError('');
154
+ try {
155
+ // Worst-off first, from the SERVER — that is what the stored `progress`
156
+ // field buys, and why nothing here recomputes it on the way past.
157
+ const page = await apiRef.current.listFocuses({ orderBy: 'progress', pageSize: 100 });
158
+ setFocuses(page.results);
159
+ return page.results;
160
+ } catch (err) {
161
+ setFocuses([]);
162
+ setError(failureMessage(err));
163
+ return [] as Focus[];
164
+ } finally {
165
+ setLoadingFocuses(false);
166
+ }
167
+ }, []);
168
+
169
+ React.useEffect(() => {
170
+ let live = true;
171
+ void (async () => {
172
+ const loaded = await loadFocuses();
173
+ if (!live || !apiRef.current.focusStanding) return;
174
+ // Bounded fan-out, and the clock is passed IN so "overdue" is computed
175
+ // against the frozen mount time rather than a fresh Date.now() per call.
176
+ const standing = apiRef.current.focusStanding.bind(apiRef.current);
177
+ for (let i = 0; i < loaded.length && live; i += STANDING_CONCURRENCY) {
178
+ const slice = loaded.slice(i, i + STANDING_CONCURRENCY);
179
+ const results = await Promise.all(
180
+ slice.map((focus) => standing(focus.id, clock).catch(() => null)),
181
+ );
182
+ if (!live) return;
183
+ setStandings((prev) => {
184
+ const next = { ...prev };
185
+ for (const result of results) if (result) next[result.focusId] = result;
186
+ return next;
187
+ });
188
+ }
189
+ })();
190
+ return () => { live = false; };
191
+ // `scope.orgId` and not `scope`: an app that rebuilds the scope object every
192
+ // render must not turn that into a refetch loop.
193
+ }, [loadFocuses, clock, scope.orgId, epoch]);
194
+
195
+ // -- items ---------------------------------------------------------------
196
+
197
+ const itemQuery = React.useMemo<CommitmentQuery>(() => ({
198
+ status: showDone ? 'done' : (['open', 'blocked'] as CommitmentStatus[]),
199
+ ...(owner.trim() ? { owner: owner.trim() } : {}),
200
+ ...(selected ? { focus: selected } : {}),
201
+ pageSize: 200,
202
+ }), [showDone, owner, selected]);
203
+
204
+ React.useEffect(() => {
205
+ let live = true;
206
+ setLoadingItems(true);
207
+ void apiRef.current
208
+ .listCommitments(itemQuery)
209
+ .then((page) => {
210
+ if (!live) return;
211
+ setItems(page.results);
212
+ setApplied(page.appliedFilters ?? []);
213
+ })
214
+ .catch((err) => { if (live) { setItems([]); setError(failureMessage(err)); } })
215
+ .finally(() => { if (live) setLoadingItems(false); });
216
+ return () => { live = false; };
217
+ }, [itemQuery, scope.orgId, epoch]);
218
+
219
+ React.useEffect(() => {
220
+ let live = true;
221
+ if (!api.listMembers) return undefined;
222
+ void apiRef.current.listMembers?.()
223
+ .then((roster) => { if (live) setMembers(roster); })
224
+ .catch(() => { /* the picker degrades to free text — see listMembers */ });
225
+ return () => { live = false; };
226
+ }, [api.listMembers, scope.orgId]);
227
+
228
+ /**
229
+ * Narrowed again in the browser, over what the server returned.
230
+ *
231
+ * NOT redundant with the server filter: a deployment whose API ignores
232
+ * `status` would otherwise render closed items under an "Outstanding" heading
233
+ * with only the banner to explain it. The banner says the narrowing happened;
234
+ * this is the narrowing.
235
+ */
236
+ const visible = React.useMemo(() => {
237
+ const wanted = showDone
238
+ ? (item: Commitment) => item.status === 'done' || item.status === 'dropped'
239
+ : (item: Commitment) => item.status === 'open' || item.status === 'blocked';
240
+ const byOwner = owner.trim().toLowerCase();
241
+ return items
242
+ .filter(wanted)
243
+ .filter((item) => !byOwner || (item.ownerName ?? '').toLowerCase().includes(byOwner))
244
+ .sort(compareForScreen(clock));
245
+ }, [items, showDone, owner, clock]);
246
+
247
+ const refresh = React.useCallback(() => setEpoch((n) => n + 1), []);
248
+ const selectedFocus = focuses.find((focus) => focus.id === selected) ?? null;
249
+ const overdueCount = visible.filter((item) => isOverdue(item, clock)).length;
250
+
251
+ return (
252
+ <div className={cn.root}>
253
+ <section className={cn.section}>
254
+ <div className="flex flex-wrap items-center gap-2">
255
+ <h2 className={cn.heading}>Focuses</h2>
256
+ {canAuthor ? (
257
+ <NewFocusForm
258
+ classNames={cn}
259
+ onCreate={async (draft) => {
260
+ await apiRef.current.createFocus!(draft);
261
+ refresh();
262
+ }}
263
+ onError={setError}
264
+ />
265
+ ) : null}
266
+ </div>
267
+
268
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
269
+
270
+ {loadingFocuses ? <p className={cn.muted}>Loading…</p> : null}
271
+
272
+ {!loadingFocuses && focuses.length === 0 ? (
273
+ <p className={cn.muted}>
274
+ {/* Advice, and only when it is true. A workspace with no focuses is
275
+ a workspace nobody has set one up in — distinct from a filtered
276
+ view that came back empty, which is the distinction the tracker
277
+ had to learn the hard way. */}
278
+ No focuses yet. A focus is something you are trying to achieve — a
279
+ launch, a quarter’s revenue, a client matter — and items get filed
280
+ under it.
281
+ </p>
282
+ ) : null}
283
+
284
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
285
+ {focuses.map((focus) => (
286
+ <FocusCard
287
+ key={focus.id}
288
+ focus={focus}
289
+ standing={standings[focus.id] ?? null}
290
+ selected={focus.id === selected}
291
+ classNames={cn}
292
+ onSelect={() => setSelected(focus.id === selected ? null : focus.id)}
293
+ />
294
+ ))}
295
+ </div>
296
+ </section>
297
+
298
+ <section className={cn.section} aria-label="Items">
299
+ <div className="flex flex-wrap items-center gap-2">
300
+ <h2 className={cn.heading}>
301
+ {loadingItems
302
+ ? 'Loading…'
303
+ : `${visible.length} item${visible.length === 1 ? '' : 's'}` +
304
+ (selectedFocus ? ` in ${selectedFocus.title}` : ' across every focus') +
305
+ (overdueCount ? ` · ${overdueCount} overdue` : '')}
306
+ </h2>
307
+
308
+ {selected ? (
309
+ <button type="button" className={cn.secondaryButton} onClick={() => setSelected(null)}>
310
+ Show everything
311
+ </button>
312
+ ) : null}
313
+
314
+ <label className="ml-auto flex items-center gap-2">
315
+ <span className="sr-only">Filter by owner</span>
316
+ <input
317
+ className={cn.input}
318
+ value={owner}
319
+ placeholder="Owner"
320
+ onChange={(event) => setOwner(event.target.value)}
321
+ />
322
+ </label>
323
+
324
+ <button
325
+ type="button"
326
+ className={showDone ? cn.button : cn.secondaryButton}
327
+ aria-pressed={showDone}
328
+ onClick={() => setShowDone((on) => !on)}
329
+ >
330
+ {showDone ? 'Showing closed' : 'Show closed'}
331
+ </button>
332
+ </div>
333
+
334
+ <UnfilteredNotice
335
+ requested={PLANNING_FILTER_KEYS}
336
+ applied={applied}
337
+ query={itemQuery}
338
+ className={cn.warning}
339
+ />
340
+
341
+ {canAuthor ? (
342
+ <NewItemForm
343
+ classNames={cn}
344
+ members={members}
345
+ focusId={selected}
346
+ focusTitle={selectedFocus?.title ?? null}
347
+ onCreate={async (draft) => {
348
+ await apiRef.current.createCommitment!(draft);
349
+ refresh();
350
+ }}
351
+ onError={setError}
352
+ />
353
+ ) : null}
354
+
355
+ {!loadingItems && visible.length === 0 ? (
356
+ <p className={cn.muted}>
357
+ {selectedFocus
358
+ ? 'Nothing is filed under this focus yet.'
359
+ : 'Nothing matches this view.'}
360
+ </p>
361
+ ) : null}
362
+
363
+ {visible.map((item) => (
364
+ <WorkItemRow
365
+ key={item.externalId}
366
+ item={item}
367
+ now={clock}
368
+ classNames={cn}
369
+ onOpenDocument={onOpenDocument}
370
+ actions={
371
+ api.fileCommitment && focuses.length ? (
372
+ <FileControl
373
+ item={item}
374
+ focuses={focuses}
375
+ classNames={cn}
376
+ onFile={async (focusId) => {
377
+ await apiRef.current.fileCommitment!(item, focusId);
378
+ refresh();
379
+ }}
380
+ onError={setError}
381
+ />
382
+ ) : null
383
+ }
384
+ />
385
+ ))}
386
+ </section>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+
393
+ function FocusCard({
394
+ focus, standing, selected, classNames: cn, onSelect,
395
+ }: {
396
+ focus: Focus;
397
+ standing: FocusStanding | null;
398
+ selected: boolean;
399
+ classNames: Record<PlanningSlot, string>;
400
+ onSelect: () => void;
401
+ }) {
402
+ const progress = displayProgress(focus, standing);
403
+
404
+ return (
405
+ <button
406
+ type="button"
407
+ onClick={onSelect}
408
+ aria-pressed={selected}
409
+ className={`${cn.card} text-left ${selected ? 'ring-2 ring-gray-900' : ''}`}
410
+ >
411
+ <div className="flex flex-wrap items-center gap-2">
412
+ <span className={cn.badge}>{focus.status.replace(/_/g, ' ')}</span>
413
+ {focus.category ? <span className={cn.badge}>{focus.category}</span> : null}
414
+ {focus.timeFrame ? (
415
+ <span className={cn.badge}>
416
+ {focus.timeFrame}{focus.year ? ` ${focus.year}` : ''}
417
+ </span>
418
+ ) : null}
419
+ </div>
420
+
421
+ <h3 className="mt-2 text-sm font-medium text-gray-900">{focus.title}</h3>
422
+ {focus.detail ? <p className={`mt-1 ${cn.muted}`}>{focus.detail}</p> : null}
423
+
424
+ {progress !== null ? (
425
+ <div className="mt-3">
426
+ <div className={cn.progressTrack}>
427
+ <div className={cn.progressBar} style={{ width: `${Math.round(progress * 100)}%` }} />
428
+ </div>
429
+ </div>
430
+ ) : null}
431
+
432
+ <p className={`mt-2 ${cn.muted}`}>
433
+ {standing === null ? (
434
+ // Not "0 of 0". A standing that has not arrived and a focus with
435
+ // nothing under it are different facts, and only one of them is
436
+ // actionable.
437
+ 'Counting…'
438
+ ) : standing.total === 0 ? (
439
+ 'Nothing filed under this yet'
440
+ ) : (
441
+ <>
442
+ {standing.done} of {standing.total} done
443
+ {standing.overdue ? ` · ${standing.overdue} overdue` : ''}
444
+ </>
445
+ )}
446
+ {focus.targetValue !== undefined && focus.targetValue !== null ? (
447
+ <> · {focus.currentValue ?? 0} / {focus.targetValue} {focus.unit ?? ''}</>
448
+ ) : null}
449
+ </p>
450
+ {focus.ownerName ? <p className={`mt-1 ${cn.muted}`}>{focus.ownerName}</p> : null}
451
+ </button>
452
+ );
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+
457
+ function NewFocusForm({
458
+ classNames: cn, onCreate, onError,
459
+ }: {
460
+ classNames: Record<PlanningSlot, string>;
461
+ onCreate: (draft: FocusDraft) => Promise<void>;
462
+ onError: (message: string) => void;
463
+ }) {
464
+ const [open, setOpen] = React.useState(false);
465
+ const [title, setTitle] = React.useState('');
466
+ const [status, setStatus] = React.useState<FocusStatus>('active');
467
+ const [busy, setBusy] = React.useState(false);
468
+
469
+ if (!open) {
470
+ return (
471
+ <button type="button" className={`${cn.secondaryButton} ml-auto`} onClick={() => setOpen(true)}>
472
+ New focus
473
+ </button>
474
+ );
475
+ }
476
+
477
+ return (
478
+ <form
479
+ className="ml-auto flex flex-wrap items-center gap-2"
480
+ onSubmit={async (event) => {
481
+ event.preventDefault();
482
+ if (!title.trim()) return;
483
+ setBusy(true);
484
+ try {
485
+ await onCreate({ title: title.trim(), status });
486
+ setTitle('');
487
+ setOpen(false);
488
+ } catch (err) {
489
+ onError(failureMessage(err));
490
+ } finally {
491
+ setBusy(false);
492
+ }
493
+ }}
494
+ >
495
+ <label>
496
+ <span className="sr-only">Focus title</span>
497
+ <input
498
+ className={cn.input}
499
+ value={title}
500
+ placeholder="What are you trying to achieve?"
501
+ onChange={(event) => setTitle(event.target.value)}
502
+ />
503
+ </label>
504
+ <label>
505
+ <span className="sr-only">Status</span>
506
+ <select
507
+ className={cn.input}
508
+ value={status}
509
+ onChange={(event) => setStatus(event.target.value as FocusStatus)}
510
+ >
511
+ {FOCUS_STATUS_OPTIONS.map((option) => (
512
+ <option key={option} value={option}>{option.replace(/_/g, ' ')}</option>
513
+ ))}
514
+ </select>
515
+ </label>
516
+ <button type="submit" className={cn.button} disabled={busy || !title.trim()}>
517
+ {busy ? 'Adding…' : 'Add focus'}
518
+ </button>
519
+ <button type="button" className={cn.secondaryButton} onClick={() => setOpen(false)}>
520
+ Cancel
521
+ </button>
522
+ </form>
523
+ );
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+
528
+ function NewItemForm({
529
+ classNames: cn, members, focusId, focusTitle, onCreate, onError,
530
+ }: {
531
+ classNames: Record<PlanningSlot, string>;
532
+ members: Array<{ sub: string; email: string; name?: string }>;
533
+ focusId: string | null;
534
+ focusTitle: string | null;
535
+ onCreate: (draft: AuthoredItem) => Promise<void>;
536
+ onError: (message: string) => void;
537
+ }) {
538
+ const [title, setTitle] = React.useState('');
539
+ const [subtype, setSubtype] = React.useState('action');
540
+ const [ownerSub, setOwnerSub] = React.useState('');
541
+ const [dueAt, setDueAt] = React.useState('');
542
+ const [priority, setPriority] = React.useState('');
543
+ const [busy, setBusy] = React.useState(false);
544
+
545
+ const member = members.find((entry) => entry.sub === ownerSub);
546
+
547
+ return (
548
+ <form
549
+ className={`${cn.card} flex flex-wrap items-end gap-2`}
550
+ onSubmit={async (event) => {
551
+ event.preventDefault();
552
+ if (!title.trim()) return;
553
+ setBusy(true);
554
+ try {
555
+ await onCreate({
556
+ title: title.trim(),
557
+ subtype,
558
+ ...(ownerSub ? { ownerSub } : {}),
559
+ // The NAME travels with the id, and this is the point of
560
+ // bd startsim-768w.38.29: the roster is the join, but the row must
561
+ // still read correctly on a screen that has no roster — and beside
562
+ // extracted rows whose owner was only ever a name in a transcript.
563
+ ...(member ? { ownerName: member.name || member.email } : {}),
564
+ ...(dueAt ? { dueAt: new Date(dueAt).toISOString() } : {}),
565
+ ...(priority !== '' ? { priority: Number(priority) } : {}),
566
+ ...(focusId ? { focusId } : {}),
567
+ });
568
+ setTitle('');
569
+ setDueAt('');
570
+ setPriority('');
571
+ } catch (err) {
572
+ onError(failureMessage(err));
573
+ } finally {
574
+ setBusy(false);
575
+ }
576
+ }}
577
+ >
578
+ <label className="flex-1">
579
+ <span className={cn.muted}>
580
+ {focusTitle ? `New item in ${focusTitle}` : 'New item (not filed under a focus)'}
581
+ </span>
582
+ <input
583
+ className={`${cn.input} mt-1 w-full`}
584
+ value={title}
585
+ placeholder="What needs doing?"
586
+ onChange={(event) => setTitle(event.target.value)}
587
+ />
588
+ </label>
589
+
590
+ <label>
591
+ <span className="sr-only">Kind</span>
592
+ <select
593
+ className={cn.input}
594
+ value={subtype}
595
+ onChange={(event) => setSubtype(event.target.value)}
596
+ >
597
+ {SUBTYPE_OPTIONS.map((option) => (
598
+ <option key={option} value={option}>{option}</option>
599
+ ))}
600
+ </select>
601
+ </label>
602
+
603
+ {members.length ? (
604
+ <label>
605
+ <span className="sr-only">Owner</span>
606
+ <select
607
+ className={cn.input}
608
+ value={ownerSub}
609
+ onChange={(event) => setOwnerSub(event.target.value)}
610
+ >
611
+ <option value="">Unassigned</option>
612
+ {members.map((entry) => (
613
+ <option key={entry.sub} value={entry.sub}>{entry.name || entry.email}</option>
614
+ ))}
615
+ </select>
616
+ </label>
617
+ ) : null}
618
+
619
+ <label>
620
+ <span className="sr-only">Due date</span>
621
+ <input
622
+ type="date"
623
+ className={cn.input}
624
+ value={dueAt}
625
+ onChange={(event) => setDueAt(event.target.value)}
626
+ />
627
+ </label>
628
+
629
+ <label>
630
+ <span className="sr-only">Priority</span>
631
+ <select
632
+ className={cn.input}
633
+ value={priority}
634
+ onChange={(event) => setPriority(event.target.value)}
635
+ >
636
+ {/* Blank is the default and it MEANS untriaged. Defaulting to Medium
637
+ would put every new row in the middle of a triage list as though
638
+ somebody had judged it. */}
639
+ <option value="">No priority</option>
640
+ {PRIORITY_OPTIONS.map((option) => (
641
+ <option key={option.value} value={option.value}>{option.label}</option>
642
+ ))}
643
+ </select>
644
+ </label>
645
+
646
+ <button type="submit" className={cn.button} disabled={busy || !title.trim()}>
647
+ {busy ? 'Adding…' : 'Add item'}
648
+ </button>
649
+ </form>
650
+ );
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+
655
+ function FileControl({
656
+ item, focuses, classNames: cn, onFile, onError,
657
+ }: {
658
+ item: Commitment;
659
+ focuses: Focus[];
660
+ classNames: Record<PlanningSlot, string>;
661
+ onFile: (focusId: string | null) => Promise<void>;
662
+ onError: (message: string) => void;
663
+ }) {
664
+ const [busy, setBusy] = React.useState(false);
665
+ return (
666
+ <label>
667
+ <span className="sr-only">File {item.title} under a focus</span>
668
+ <select
669
+ className={cn.input}
670
+ disabled={busy}
671
+ value={item.focusId ?? ''}
672
+ onChange={async (event) => {
673
+ const next = event.target.value || null;
674
+ setBusy(true);
675
+ try {
676
+ await onFile(next);
677
+ } catch (err) {
678
+ onError(failureMessage(err));
679
+ } finally {
680
+ setBusy(false);
681
+ }
682
+ }}
683
+ >
684
+ <option value="">Unfiled</option>
685
+ {focuses.map((focus) => (
686
+ <option key={focus.id} value={focus.id}>{focus.title}</option>
687
+ ))}
688
+ </select>
689
+ </label>
690
+ );
691
+ }