@startsimpli/integrations 0.2.25 → 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__/corpus-planning.test.tsx +556 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- 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
|
@@ -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
|
+
}
|