@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,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
+ });
@@ -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';
@@ -85,6 +85,29 @@ function readFailureMessage(err: unknown): string {
85
85
  return (err as { message?: string })?.message || 'Could not load the library.';
86
86
  }
87
87
 
88
+ /**
89
+ * Why the record the screen was asked to open is not on the screen
90
+ * (bd startsim-2lt8y).
91
+ *
92
+ * FOUR ANSWERS, NOT ONE, and the branch is the point: "sign in again", "you may
93
+ * not see this", "that record is not here" and "something went wrong" send the
94
+ * reader to four different places, and only one of them is true at a time.
95
+ * Calling an expired session a missing record is a different lie from the one
96
+ * this bead is fixing, not a smaller version of it.
97
+ *
98
+ * The id is NAMED because it came out of the address bar. A citation or a
99
+ * bookmark that no longer resolves is worth being able to quote back to
100
+ * somebody, and "a record" is not quotable.
101
+ */
102
+ function openFailureMessage(err: unknown, id: string): string {
103
+ const status = (err as { status?: number })?.status;
104
+ if (status === 404) {
105
+ return `That record could not be found in this workspace (${id}). It may have been removed, or it may not be one you can see.`;
106
+ }
107
+ if (status === 401 || status === 403) return readFailureMessage(err);
108
+ return (err as { message?: string })?.message || `That record could not be opened (${id}).`;
109
+ }
110
+
88
111
  function formatDate(value: string | null | undefined): string {
89
112
  if (!value) return 'No date';
90
113
  const at = Date.parse(value);
@@ -160,6 +183,13 @@ export function LibraryPage({
160
183
  const [parties, setParties] = React.useState<Party[]>([]);
161
184
  const [selectedId, setSelectedId] = React.useState<string | null>(initialDocumentId ?? null);
162
185
  const [selected, setSelected] = React.useState<SourceDocument | null>(null);
186
+ /**
187
+ * Why the open record is not open. Held separately from `loadError`, which is
188
+ * about the LIST: a Library that loaded fine and a citation that no longer
189
+ * resolves are different facts, and blanking the list for the second one would
190
+ * be a worse answer than the silence this replaces.
191
+ */
192
+ const [selectError, setSelectError] = React.useState('');
163
193
  const [loading, setLoading] = React.useState(true);
164
194
  const [loadError, setLoadError] = React.useState('');
165
195
  /**
@@ -216,18 +246,22 @@ export function LibraryPage({
216
246
  }, [api, filters, scope]);
217
247
 
218
248
  React.useEffect(() => {
219
- if (!selectedId) { setSelected(null); return; }
249
+ if (!selectedId) { setSelected(null); setSelectError(''); return; }
220
250
  let live = true;
221
251
  void (async () => {
222
252
  // Prefer the row already on screen; fall back to a fetch so a citation
223
253
  // deep-link opens a record that is not on the current page.
224
254
  const onPage = documents.find((document) => document.id === selectedId);
225
- if (onPage) { setSelected(onPage); return; }
255
+ if (onPage) { setSelected(onPage); setSelectError(''); return; }
226
256
  try {
227
257
  const fetched = await api.getDocument(selectedId);
228
- if (live) setSelected(fetched);
229
- } catch {
230
- if (live) setSelected(null);
258
+ if (live) { setSelected(fetched); setSelectError(''); }
259
+ } catch (err) {
260
+ // THE REPORTED BUG (bd startsim-4s4um). This was `setSelected(null)` and
261
+ // nothing else, so `?doc=<id>` in the address bar produced a Library
262
+ // with no record and no explanation — the one outcome a reader cannot
263
+ // act on, and indistinguishable from a page that simply ignored them.
264
+ if (live) { setSelected(null); setSelectError(openFailureMessage(err, selectedId)); }
231
265
  }
232
266
  })();
233
267
  return () => { live = false; };
@@ -235,6 +269,7 @@ export function LibraryPage({
235
269
 
236
270
  const select = React.useCallback((id: string | null) => {
237
271
  setSelectedId(id);
272
+ if (!id) setSelectError('');
238
273
  onSelectDocument?.(id);
239
274
  }, [onSelectDocument]);
240
275
 
@@ -459,6 +494,16 @@ export function LibraryPage({
459
494
  onFilterByParty={(partyId) => update({ partyId })}
460
495
  />
461
496
  ) : null}
497
+
498
+ {/* Asked for, and not shown — said out loud rather than left blank. */}
499
+ {!selected && selectError ? (
500
+ <section className={cn.card} aria-label="Record">
501
+ <div className="flex items-start justify-between gap-4">
502
+ <p className={cn.warning} role="alert">{selectError}</p>
503
+ <button type="button" className={cn.button} onClick={() => select(null)}>Close</button>
504
+ </div>
505
+ </section>
506
+ ) : null}
462
507
  </div>
463
508
  );
464
509
  }