@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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * THE PLANNING TOOLSET (bd startsim-8l6ef).
3
+ *
4
+ * The screen makes one claim the tracker does not — "an extracted item and one
5
+ * you typed are the same list, and you can still tell them apart" — and every
6
+ * case below is a way that could be false on screen while the database is fine.
7
+ *
8
+ * Three of them are regression tests for bugs that were live in this branch and
9
+ * would each have shipped a silent wrong answer rather than an error:
10
+ *
11
+ * 1. a `Commitment.origin` field whose mapper wrote the key `origin` while the
12
+ * tenant indexes `_origin` — every filter over it returns an empty page;
13
+ * 2. an authored item with no `first_seen_at`/`last_seen_at`, which vanishes
14
+ * from three of the tracker's four tabs while sitting in the same table;
15
+ * 3. a `focus` filter the server answers as `related_to`, which — unreconciled
16
+ * — makes the screen announce that the workspace did not filter by focus
17
+ * while it verifiably did.
18
+ */
19
+ import * as React from 'react';
20
+ import { render, screen, waitFor } from '@testing-library/react';
21
+ import { describe, expect, it, vi } from 'vitest';
22
+
23
+ import { PlanningPage } from '../components/pages/PlanningPage';
24
+ import { createPlanningApi } from '../corpus/client';
25
+ import { ORIGIN, ORIGIN_KEY, commitmentToEntity, toCommitment } from '../corpus/entities';
26
+ import {
27
+ authoredItemToEntity,
28
+ displayProgress,
29
+ focusProgress,
30
+ focusSlug,
31
+ focusToEntity,
32
+ isAuthored,
33
+ toFocus,
34
+ } from '../corpus/planning';
35
+ import type {
36
+ Commitment,
37
+ CorpusPage,
38
+ Focus,
39
+ FocusStanding,
40
+ PlanningApi,
41
+ } from '../corpus/types';
42
+
43
+ const NOW = Date.parse('2026-08-26T12:00:00Z');
44
+ const SCOPE = { orgId: 'tandem' };
45
+
46
+ function commitment(
47
+ overrides: Partial<Commitment> & { externalId: string; title: string },
48
+ ): Commitment {
49
+ return {
50
+ id: overrides.externalId,
51
+ dedupeKey: overrides.externalId.replace('commit:', ''),
52
+ subtype: 'action',
53
+ detail: '',
54
+ status: 'open',
55
+ subjectKey: '',
56
+ actionKey: '',
57
+ mentions: [{
58
+ documentId: 'doc_1',
59
+ originKey: 'rules:x',
60
+ occurredAt: '2026-08-01T15:00:00Z',
61
+ quote: 'Jonathan to complete a filing before the next call.',
62
+ extractor: 'rules',
63
+ }],
64
+ firstSeenAt: '2026-08-01T15:00:00Z',
65
+ lastSeenAt: '2026-08-01T15:00:00Z',
66
+ extractorVersion: 'rules+model@1',
67
+ source: 'granola',
68
+ sourceLabel: 'Granola',
69
+ scope: SCOPE,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // the origin key
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe('_origin — the key the tenant actually indexes', () => {
79
+ it('writes the literal underscore key, not a field named origin', () => {
80
+ /**
81
+ * THE BUG THIS PINS. `camelToSnakeKey('origin')` is `origin`, a DIFFERENT
82
+ * `data` key from `_origin` — which is what three writers have used for
83
+ * months and what the tenant declares an AttributeDef for. A mapper that
84
+ * emitted `origin` would write to a key nothing indexes, and every
85
+ * `?attr._origin=` filter would answer 200/count:0 forever with no error.
86
+ */
87
+ const payload = commitmentToEntity(
88
+ commitment({ externalId: 'item:a', title: 'Renew the lease', origin: ORIGIN.authored }),
89
+ );
90
+ expect(payload.data[ORIGIN_KEY]).toBe('human');
91
+ expect(ORIGIN_KEY).toBe('_origin');
92
+ expect(payload.data).not.toHaveProperty('origin');
93
+ });
94
+
95
+ it('does not relabel an authored item as extracted when it passes back through the bulk upsert', () => {
96
+ // Otherwise a human's item would come back claiming a meeting said it — and
97
+ // it has no mention to contradict the claim.
98
+ const payload = commitmentToEntity(
99
+ commitment({ externalId: 'item:a', title: 'Renew the lease', origin: ORIGIN.authored }),
100
+ );
101
+ expect(payload.data[ORIGIN_KEY]).toBe(ORIGIN.authored);
102
+ });
103
+
104
+ it('still marks an extractor-written row as extracted', () => {
105
+ const payload = commitmentToEntity(
106
+ commitment({ externalId: 'commit:a', title: 'Chase the inventor' }),
107
+ );
108
+ expect(payload.data[ORIGIN_KEY]).toBe(ORIGIN.extracted);
109
+ });
110
+
111
+ it('reads BOTH spellings, because the browser client camelCases the key', () => {
112
+ /**
113
+ * `snakeToCamelKey('_origin')` is `Origin`, so a row read through
114
+ * @startsimpli/api in a browser arrives under that name while a plain
115
+ * server-side fetch sees `_origin`. Reading only one spelling is the bug
116
+ * that made the deployed Tracker report a filter gap that did not exist.
117
+ */
118
+ const fromServer = toCommitment(
119
+ { id: '1', external_id: 'item:a', name: 'x', data: { title: 'x', _origin: 'human' } },
120
+ SCOPE,
121
+ );
122
+ const fromBrowser = toCommitment(
123
+ { id: '1', external_id: 'item:a', name: 'x', data: { title: 'x', Origin: 'human' } },
124
+ SCOPE,
125
+ );
126
+ expect(fromServer.origin).toBe('human');
127
+ expect(fromBrowser.origin).toBe('human');
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // authored items
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('an authored item', () => {
136
+ it('stamps both timestamps, or it disappears from three of four tabs', () => {
137
+ /**
138
+ * THE BUG THIS PINS, and it is invisible rather than loud. "Changed this
139
+ * week" filters on `last_seen_at`; the age line reads `first_seen_at`; the
140
+ * server-side `changedAfter` is a `attr.last_seen_at__gte` that a null never
141
+ * matches. An authored item with neither sits in the same table as the
142
+ * extracted ones and shows up in one view out of four — which makes the
143
+ * whole "one list" claim false on screen while it stays true in the DB.
144
+ */
145
+ const payload = authoredItemToEntity({ title: 'Renew the office lease' }, { now: NOW });
146
+ expect(payload.data.first_seen_at).toBe(new Date(NOW).toISOString());
147
+ expect(payload.data.last_seen_at).toBe(new Date(NOW).toISOString());
148
+ });
149
+
150
+ it('cannot collide with an extracted row', () => {
151
+ const payload = authoredItemToEntity({ title: 'x' }, { id: 'abc', now: NOW });
152
+ expect(payload.external_id).toBe('item:abc');
153
+ expect(payload.external_id.startsWith('commit:')).toBe(false);
154
+ });
155
+
156
+ it('writes an EMPTY mention list rather than omitting the key', () => {
157
+ // The row renderer branches on "no mentions"; "authored, so no meeting" and
158
+ // "the key is missing" must not be the same shape.
159
+ const payload = authoredItemToEntity({ title: 'x' }, { now: NOW });
160
+ expect(payload.data.mentions).toEqual([]);
161
+ expect(payload.data.mention_count).toBe(0);
162
+ });
163
+
164
+ it('defaults to an action, and carries a requirement when asked', () => {
165
+ expect(authoredItemToEntity({ title: 'x' }, { now: NOW }).data.subtype).toBe('action');
166
+ // `requirement` costs nothing precisely because CommitmentSubtype is a
167
+ // string rather than an enum.
168
+ expect(
169
+ authoredItemToEntity({ title: 'x', subtype: 'requirement' }, { now: NOW }).data.subtype,
170
+ ).toBe('requirement');
171
+ });
172
+
173
+ it('is recognisable as authored from either the origin or the id', () => {
174
+ expect(isAuthored(commitment({ externalId: 'item:a', title: 'x' }))).toBe(true);
175
+ expect(isAuthored(commitment({
176
+ externalId: 'x', title: 'x', origin: ORIGIN.authored,
177
+ }))).toBe(true);
178
+ expect(isAuthored(commitment({ externalId: 'commit:a', title: 'x' }))).toBe(false);
179
+ });
180
+
181
+ it('lands in the same list as an extracted item, and reads honestly', async () => {
182
+ // The placement argument, on screen. Both rows render; each says where it
183
+ // came from; neither claims a meeting it did not have.
184
+ render(
185
+ <PlanningPage
186
+ api={fakeApi({
187
+ items: [
188
+ commitment({ externalId: 'commit:a', title: 'Chase the inventor for 2148' }),
189
+ {
190
+ ...commitment({ externalId: 'item:b', title: 'Renew the office lease' }),
191
+ mentions: [], origin: ORIGIN.authored, firstSeenAt: null, lastSeenAt: null,
192
+ },
193
+ ],
194
+ })}
195
+ scope={SCOPE}
196
+ now={NOW}
197
+ />,
198
+ );
199
+ expect(await screen.findByText('Chase the inventor for 2148')).toBeTruthy();
200
+ expect(screen.getByText('Renew the office lease')).toBeTruthy();
201
+ expect(screen.getByText('Added by hand')).toBeTruthy();
202
+ // ...and the extracted one still cites its meeting rather than being
203
+ // flattened into the same line.
204
+ expect(screen.getByText('From 1 meeting')).toBeTruthy();
205
+ // The bug this replaces: "From 0 meetings" over an empty disclosure.
206
+ expect(screen.queryByText('From 0 meetings')).toBeNull();
207
+ });
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // focuses
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('focus mapping', () => {
215
+ it('keeps an absent target absent rather than coercing it to zero', () => {
216
+ /**
217
+ * `target_value`'s absence is `Goal.is_measurable`'s switch. A 0 would turn
218
+ * every qualitative focus into a measurable one that is fully achieved.
219
+ */
220
+ const focus = toFocus(
221
+ { id: '1', external_id: 'focus:a', name: 'Ship v2', data: { title: 'Ship v2' } },
222
+ SCOPE,
223
+ );
224
+ expect(focus.targetValue).toBeUndefined();
225
+ expect(focus.currentValue).toBeUndefined();
226
+ expect(focusProgress(focus, null)).toBeNull();
227
+ });
228
+
229
+ it('falls back to draft rather than active on an unrecognised status', () => {
230
+ // A row whose status did not survive a round trip must not present as work
231
+ // somebody committed to.
232
+ const focus = toFocus(
233
+ { id: '1', external_id: 'focus:a', name: 'x', data: { title: 'x', status: 'nonsense' } },
234
+ SCOPE,
235
+ );
236
+ expect(focus.status).toBe('draft');
237
+ });
238
+
239
+ it('round-trips the Goal vocabulary', () => {
240
+ const payload = focusToEntity({
241
+ title: 'Close the Series A', status: 'at_risk', category: 'financial',
242
+ timeFrame: 'Q3', year: 2026, targetValue: 2_000_000, currentValue: 500_000, unit: 'usd',
243
+ });
244
+ expect(payload.data).toMatchObject({
245
+ title: 'Close the Series A', status: 'at_risk', category: 'financial',
246
+ time_frame: 'Q3', year: 2026, target_value: 2_000_000, current_value: 500_000, unit: 'usd',
247
+ });
248
+ const back = toFocus({ id: '1', external_id: payload.external_id, name: payload.name, data: payload.data }, SCOPE);
249
+ expect(back.status).toBe('at_risk');
250
+ expect(back.timeFrame).toBe('Q3');
251
+ expect(back.targetValue).toBe(2_000_000);
252
+ });
253
+
254
+ it('derives a slug once, so renaming does not fork the row', () => {
255
+ expect(focusSlug('Close the Series A!')).toBe('close-the-series-a');
256
+ expect(focusSlug(' ')).toBe('untitled');
257
+ expect(focusToEntity({ title: 'Close the Series A' }).external_id).toBe('focus:close-the-series-a');
258
+ // An explicit external_id wins, which is what an update relies on.
259
+ expect(focusToEntity({ title: 'Renamed' }, 'focus:close-the-series-a').external_id)
260
+ .toBe('focus:close-the-series-a');
261
+ });
262
+ });
263
+
264
+ describe('progress', () => {
265
+ const base = (over: Partial<Focus> = {}): Focus => ({
266
+ id: 'f1', externalId: 'focus:a', title: 'x', detail: '', status: 'active', scope: SCOPE, ...over,
267
+ });
268
+ const standing = (done: number, total: number): FocusStanding =>
269
+ ({ focusId: 'f1', done, total, overdue: 0 });
270
+
271
+ it('measures against the target when there is one', () => {
272
+ expect(focusProgress(base({ targetValue: 100, currentValue: 25 }), null)).toBe(0.25);
273
+ });
274
+
275
+ it('counts items when there is no target', () => {
276
+ expect(focusProgress(base(), standing(3, 12))).toBe(0.25);
277
+ });
278
+
279
+ it('is null — not zero — when there is nothing to say', () => {
280
+ // 0% reads as "started and got nowhere" about work nobody has filed
281
+ // anything under yet.
282
+ expect(focusProgress(base(), standing(0, 0))).toBeNull();
283
+ expect(focusProgress(base(), null)).toBeNull();
284
+ });
285
+
286
+ it('clamps rather than rendering over 100%', () => {
287
+ expect(focusProgress(base({ targetValue: 10, currentValue: 40 }), null)).toBe(1);
288
+ });
289
+
290
+ it('prefers the STORED value, because that is what the server sorts by', () => {
291
+ // A card showing a number the list was not ordered by makes the sort look
292
+ // broken.
293
+ expect(displayProgress(base({ progress: 0.9 }), standing(1, 10))).toBe(0.9);
294
+ expect(displayProgress(base(), standing(1, 10))).toBe(0.1);
295
+ });
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // the focus filter, end to end through the client
300
+ // ---------------------------------------------------------------------------
301
+
302
+ type Body = { data: Record<string, unknown> };
303
+
304
+ const FOCUS_ROW: Focus = {
305
+ id: 'f1', externalId: 'focus:a', title: 'Close the Series A', detail: '',
306
+ status: 'cancelled', scope: SCOPE,
307
+ };
308
+
309
+ /** A client whose writes can be inspected. */
310
+ function writeSpy() {
311
+ const posted: unknown[] = [];
312
+ const http = {
313
+ get: vi.fn(async () => ({ count: 0, next: null, previous: null, results: [] })),
314
+ post: vi.fn(async (_path: string, body: unknown) => {
315
+ posted.push(body);
316
+ return { id: 'f1', external_id: 'focus:a', name: 'x', data: { title: 'x' } };
317
+ }),
318
+ };
319
+ return { posted, api: createPlanningApi({ http: http as never, scope: SCOPE }) };
320
+ }
321
+
322
+ describe('filing is an edge, and the client has to say so', () => {
323
+ function httpSpy(applied: string[] = []) {
324
+ const calls: Array<{ path: string; params: Record<string, unknown> }> = [];
325
+ return {
326
+ calls,
327
+ http: {
328
+ get: vi.fn(async (path: string, options?: { params?: Record<string, unknown> }) => {
329
+ calls.push({ path, params: options?.params ?? {} });
330
+ return { count: 0, next: null, previous: null, results: [], applied_filters: applied };
331
+ }),
332
+ post: vi.fn(async () => ({ id: 'x', external_id: 'item:x', name: 'x', data: {} })),
333
+ },
334
+ };
335
+ }
336
+
337
+ it('asks for the focus as an edge walk, in the direction that returns items', async () => {
338
+ const { calls, http } = httpSpy();
339
+ const api = createPlanningApi({ http: http as never, scope: SCOPE });
340
+ await api.listCommitments({ focus: 'focus-uuid' });
341
+ const params = calls.at(-1)!.params;
342
+ expect(params.related_to).toBe('focus-uuid');
343
+ expect(params.related_via).toBe('in_focus');
344
+ // `in` here returns an empty page rather than an error, so it is asserted.
345
+ expect(params.related_direction).toBe('out');
346
+ });
347
+
348
+ it('counts a server-reported related_to as the focus filter being HONOURED', async () => {
349
+ /**
350
+ * THE BUG THIS PINS. The client asks for `focus`; the server confirms
351
+ * `related_to`. Unreconciled, the composer's banner tells the user this
352
+ * workspace's data API did not filter by focus — while the server
353
+ * verifiably did. Same lie, same cause, as reading one spelling of
354
+ * `applied_filters`.
355
+ */
356
+ const { http } = httpSpy(['related_to', 'attr.status']);
357
+ const api = createPlanningApi({ http: http as never, scope: SCOPE });
358
+ const page = await api.listCommitments({ focus: 'focus-uuid', status: 'open' });
359
+ expect(page.appliedFilters).toContain('focus');
360
+ expect(page.appliedFilters).toContain('status');
361
+ });
362
+
363
+ it('still reports a genuinely ignored filter', async () => {
364
+ const { http } = httpSpy(['attr.status']);
365
+ const api = createPlanningApi({ http: http as never, scope: SCOPE });
366
+ const page = await api.listCommitments({ focus: 'focus-uuid', status: 'open' });
367
+ expect(page.appliedFilters).not.toContain('focus');
368
+ });
369
+
370
+ it('filters origin on the underscore attribute', async () => {
371
+ const { calls, http } = httpSpy();
372
+ const api = createPlanningApi({ http: http as never, scope: SCOPE });
373
+ await api.listCommitments({ origin: ORIGIN.authored });
374
+ expect(calls.at(-1)!.params['attr._origin']).toBe('human');
375
+ });
376
+
377
+ it('counts a focus standing without pulling a single row body', async () => {
378
+ const { calls, http } = httpSpy();
379
+ const api = createPlanningApi({ http: http as never, scope: SCOPE });
380
+ await api.focusStanding!('focus-uuid', NOW);
381
+ const counting = calls.slice(-3);
382
+ expect(counting).toHaveLength(3);
383
+ for (const call of counting) expect(call.params.page_size).toBe(1);
384
+ });
385
+
386
+ it('sends a snake_cased key when its camelCase draft field was patched', async () => {
387
+ // The map exists because the two names DIFFER. A guard that compared the
388
+ // wrong side would pass on `ownerName` — where both spellings coincide —
389
+ // and drop `time_frame` silently.
390
+ const { posted, api } = writeSpy();
391
+ await api.updateFocus!(FOCUS_ROW, { timeFrame: 'Q4' });
392
+ expect((posted.at(-1) as Body).data.time_frame).toBe('Q4');
393
+ });
394
+
395
+ it('sends status when status IS what changed', async () => {
396
+ // The other half of the pair below. Together they distinguish a working
397
+ // filter from one that drops everything.
398
+ const { posted, api } = writeSpy();
399
+ await api.updateFocus!(FOCUS_ROW, { status: 'completed' });
400
+ expect((posted.at(-1) as Body).data.status).toBe('completed');
401
+ });
402
+
403
+ it('never lets an edit relabel where the row came from', async () => {
404
+ // `focusToEntity` stamps `_origin` because a CREATE needs one. Merging that
405
+ // on every edit would rewrite provenance from a rename.
406
+ const { posted, api } = writeSpy();
407
+ await api.updateFocus!(FOCUS_ROW, { title: 'Renamed' });
408
+ expect((posted.at(-1) as Body).data).not.toHaveProperty('_origin');
409
+ });
410
+
411
+ it('sends only the keys an edit actually changed', async () => {
412
+ /**
413
+ * The upsert shallow-merges, so every key in the body is a WRITE. An edit to
414
+ * an owner must not carry `focusToEntity`'s defaulted `status` — that would
415
+ * resurrect this cancelled focus from a rename, on live data, silently.
416
+ */
417
+ const { posted, api } = writeSpy();
418
+ await api.updateFocus!(FOCUS_ROW, { ownerName: 'Rae' });
419
+ const body = posted.at(-1) as Body;
420
+ expect(body.data.owner_name).toBe('Rae');
421
+ expect(body.data).not.toHaveProperty('status');
422
+ });
423
+
424
+ it('refuses to unfile when the client has no delete, rather than no-opping', async () => {
425
+ // A control that appears to unfile and does not is worse than one that is
426
+ // disabled — the row would sit under a focus it looks removed from.
427
+ const { api } = writeSpy();
428
+ const item = commitment({ externalId: 'item:a', title: 'x' });
429
+ await expect(api.fileCommitment!(item, null)).rejects.toThrow(/cannot unfile/);
430
+ });
431
+
432
+ it('files through the edge endpoint when a focus IS named', async () => {
433
+ const { posted, api } = writeSpy();
434
+ const item = commitment({ externalId: 'item:a', title: 'x' });
435
+ await api.fileCommitment!(item, 'focus-uuid');
436
+ expect(posted.at(-1)).toMatchObject({
437
+ rel_type: 'in_focus', source: item.id, target: 'focus-uuid',
438
+ });
439
+ });
440
+ });
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // the screen
444
+ // ---------------------------------------------------------------------------
445
+
446
+ function fakeApi(options: {
447
+ items?: Commitment[];
448
+ focuses?: Focus[];
449
+ standings?: Record<string, FocusStanding>;
450
+ applied?: string[];
451
+ onList?: (query: unknown) => void;
452
+ } = {}): PlanningApi {
453
+ const page = <T,>(results: T[], applied: string[] = []): CorpusPage<T> =>
454
+ ({ count: results.length, next: null, previous: null, results, appliedFilters: applied });
455
+ return {
456
+ listCommitments: async (query) => {
457
+ options.onList?.(query);
458
+ return page(options.items ?? [], options.applied ?? ['status']);
459
+ },
460
+ listFocuses: async () => page(options.focuses ?? []),
461
+ focusStanding: async (focusId) =>
462
+ options.standings?.[focusId] ?? { focusId, total: 0, done: 0, overdue: 0 },
463
+ };
464
+ }
465
+
466
+ const FOCUS: Focus = {
467
+ id: 'f1', externalId: 'focus:series-a', title: 'Close the Series A',
468
+ detail: '', status: 'at_risk', category: 'financial', scope: SCOPE,
469
+ };
470
+
471
+ describe('PlanningPage', () => {
472
+ it('says a focus has nothing filed under it, rather than "0 of 0 done"', async () => {
473
+ render(
474
+ <PlanningPage
475
+ api={fakeApi({ focuses: [FOCUS] })}
476
+ scope={SCOPE}
477
+ now={NOW}
478
+ />,
479
+ );
480
+ expect(await screen.findByText('Close the Series A')).toBeTruthy();
481
+ await waitFor(() => expect(screen.getByText('Nothing filed under this yet')).toBeTruthy());
482
+ });
483
+
484
+ it('reports done over total from counts', async () => {
485
+ render(
486
+ <PlanningPage
487
+ api={fakeApi({
488
+ focuses: [FOCUS],
489
+ standings: { f1: { focusId: 'f1', total: 40, done: 12, overdue: 3 } },
490
+ })}
491
+ scope={SCOPE}
492
+ now={NOW}
493
+ />,
494
+ );
495
+ await waitFor(() => expect(screen.getByText(/12 of 40 done/)).toBeTruthy());
496
+ expect(screen.getByText(/3 overdue/)).toBeTruthy();
497
+ });
498
+
499
+ it('advises about an empty workspace only when it is really empty', async () => {
500
+ render(<PlanningPage api={fakeApi({ focuses: [] })} scope={SCOPE} now={NOW} />);
501
+ expect(await screen.findByText(/No focuses yet/)).toBeTruthy();
502
+ });
503
+
504
+ it('stays silent about filtering the server actually did', async () => {
505
+ render(
506
+ <PlanningPage
507
+ api={fakeApi({ focuses: [FOCUS], items: [], applied: ['status', 'focus'] })}
508
+ scope={SCOPE}
509
+ now={NOW}
510
+ />,
511
+ );
512
+ await screen.findByText('Close the Series A');
513
+ await waitFor(() =>
514
+ expect(screen.queryByText(/did not filter by/)).toBeNull());
515
+ });
516
+
517
+ it('confesses when the server ignored a filter it was given', async () => {
518
+ const api = fakeApi({ focuses: [FOCUS], items: [], applied: [] });
519
+ render(<PlanningPage api={api} scope={SCOPE} now={NOW} />);
520
+ await waitFor(() => expect(screen.getByText(/did not filter by status/)).toBeTruthy());
521
+ });
522
+
523
+ it('does not loop even when the app hands it a NEW api object every render', async () => {
524
+ /**
525
+ * The 917-requests-in-ten-minutes guard, carried over from the tracker. An
526
+ * api built inline in a parent's render is a new identity every pass; if it
527
+ * is an effect dependency, the load sets state, which renders, which
528
+ * rebuilds the api.
529
+ */
530
+ let calls = 0;
531
+ function Host() {
532
+ const [, force] = React.useState(0);
533
+ React.useEffect(() => { const t = setTimeout(() => force(1), 10); return () => clearTimeout(t); }, []);
534
+ return (
535
+ <PlanningPage
536
+ api={fakeApi({ focuses: [FOCUS], onList: () => { calls += 1; } })}
537
+ scope={{ orgId: 'tandem' }}
538
+ now={NOW}
539
+ />
540
+ );
541
+ }
542
+ render(<Host />);
543
+ await screen.findByText('Close the Series A');
544
+ await new Promise((resolve) => setTimeout(resolve, 120));
545
+ expect(calls).toBeLessThanOrEqual(2);
546
+ });
547
+
548
+ it('hides authoring when the client cannot write', async () => {
549
+ // A control that appears to work and does not is worse than one that is
550
+ // absent — the read-only fork is a real deployment, not a degraded one.
551
+ render(<PlanningPage api={fakeApi({ focuses: [FOCUS] })} scope={SCOPE} now={NOW} />);
552
+ await screen.findByText('Close the Series A');
553
+ expect(screen.queryByText('New focus')).toBeNull();
554
+ expect(screen.queryByPlaceholderText('What needs doing?')).toBeNull();
555
+ });
556
+ });
@@ -31,10 +31,12 @@
31
31
  */
32
32
  import type {
33
33
  Connection,
34
+ ConnectionInstructions,
34
35
  ConnectionPermissions,
35
36
  ConnectionRun,
36
37
  ConnectorCatalogEntry,
37
38
  MemberConnectionsApi,
39
+ OAuthStart,
38
40
  } from '../components/pages/types';
39
41
  import { request } from './_fetch';
40
42
 
@@ -87,6 +89,50 @@ export const connectionsMe: MemberConnectionsApi = {
87
89
  return request<Connection>(`${BASE}/`, { method: 'POST', body: input });
88
90
  },
89
91
 
92
+ /**
93
+ * START a consent round trip on an OAuth connector (bd startsim-768w.22.25.21).
94
+ *
95
+ * ONE call, not create-then-initiate. The two halves have no independent
96
+ * meaning — a shell card with no consent URL is an orphan the member cannot
97
+ * act on, and a consent URL with no card has nowhere to put the token — so a
98
+ * refusal here leaves nothing behind.
99
+ *
100
+ * Takes `connector_key` rather than an id because the row does not exist yet;
101
+ * that is the whole bug this closes. The caller opens `authorization_url` and
102
+ * does nothing else: it is already complete, and the client has no `client_id`
103
+ * of its own to add to it.
104
+ *
105
+ * `return_to` is where the browser should land afterwards. It is VALIDATED
106
+ * server-side against this tenant's own recorded origins and folded into the
107
+ * signed state — the callback is an anonymous browser mid-redirect from the
108
+ * vendor and cannot be allowed to name its own destination.
109
+ */
110
+ startOAuth(input: {
111
+ connector_key: string;
112
+ name?: string;
113
+ config?: Record<string, unknown>;
114
+ capabilities_enabled?: string[];
115
+ return_to?: string;
116
+ }): Promise<OAuthStart> {
117
+ return request<OAuthStart>(`${BASE}/oauth/start/`, { method: 'POST', body: input });
118
+ },
119
+
120
+ /**
121
+ * RE-consent an existing card whose grant the vendor stopped accepting. The
122
+ * first connect is `startOAuth`; this one takes an id because the card is
123
+ * already there and must not be duplicated.
124
+ */
125
+ oauthInitiate(id: string, input?: { return_to?: string }): Promise<OAuthStart> {
126
+ return request<OAuthStart>(`${BASE}/${id}/oauth/initiate/`, {
127
+ method: 'POST', body: input ?? {},
128
+ });
129
+ },
130
+
131
+ /** Vendor-side setup steps, as DATA written by the connector in Python. */
132
+ connectionInstructions(id: string): Promise<ConnectionInstructions> {
133
+ return request<ConnectionInstructions>(`${BASE}/${id}/instructions/`);
134
+ },
135
+
90
136
  /**
91
137
  * Test it now, against the STORED credential. The body is deliberately empty:
92
138
  * a "test connection" button that accepts a pasted key is a second connect
@@ -32,3 +32,20 @@ export type {
32
32
  export * from './pages/LibraryPage';
33
33
  export * from './pages/AskPage';
34
34
  export * from './pages/TrackerPage';
35
+
36
+ // The planning toolset (bd startsim-8l6ef): focuses, and the items filed under
37
+ // them. `work-items` carries the pieces the tracker and the planner SHARE — the
38
+ // row, the honesty banner, the overdue/changed predicates — so two views over
39
+ // one list cannot drift into disagreeing about what they mean.
40
+ export * from './pages/PlanningPage';
41
+ export {
42
+ PRIORITY_LABELS,
43
+ UnfilteredNotice,
44
+ WORK_ITEM_DEFAULTS,
45
+ WorkItemRow,
46
+ changedSince,
47
+ compareForScreen,
48
+ isOverdue,
49
+ priorityLabel,
50
+ } from './pages/work-items';
51
+ export type { WorkItemSlot } from './pages/work-items';