@startsimpli/integrations 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/connections.test.tsx +131 -0
- package/src/__tests__/corpus-planning.test.tsx +556 -0
- package/src/api/connections.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/ConnectionsPage.tsx +139 -15
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- package/src/components/pages/types.ts +59 -0
- package/src/components/pages/work-items.tsx +256 -0
- package/src/corpus/client.ts +368 -1
- package/src/corpus/entities.ts +63 -1
- package/src/corpus/index.ts +26 -1
- package/src/corpus/planning.ts +323 -0
- package/src/corpus/types.ts +241 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FOCUSES AND AUTHORED ITEMS — the mapping layer for the planning toolset
|
|
3
|
+
* (bd startsim-8l6ef).
|
|
4
|
+
*
|
|
5
|
+
* The sibling of `entities.ts`, and split from it for the same reason
|
|
6
|
+
* `CommitmentApi` is split from `CorpusApi`: a deployment can have a corpus with
|
|
7
|
+
* no planning surface at all, and the Library and Ask screens must not start
|
|
8
|
+
* depending on a schema that may never have been declared.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS FILE IS RESPONSIBLE FOR, and it is a short list on purpose:
|
|
11
|
+
*
|
|
12
|
+
* IDENTITY an authored row needs one, and it must not be able to collide
|
|
13
|
+
* with an extracted row's `commit:<dedupeKey>`.
|
|
14
|
+
* TIMESTAMPS an authored item has no meeting to date it, so the authoring
|
|
15
|
+
* path stamps its own — see `authoredItemToEntity`, where getting
|
|
16
|
+
* this wrong hides authored items from three of four tabs.
|
|
17
|
+
* PROGRESS arithmetic over counts, and NOTHING that writes.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT IS DELIBERATELY NOT RESPONSIBLE FOR: filing. An item belongs to a
|
|
20
|
+
* focus by way of an `in_focus` RELATIONSHIP, which is a second write with no
|
|
21
|
+
* transaction around it, so it lives in the client next to the other edge
|
|
22
|
+
* writes rather than being smuggled into an entity payload here.
|
|
23
|
+
*/
|
|
24
|
+
import { ORIGIN, ORIGIN_KEY, type TenantEntity } from './entities';
|
|
25
|
+
import type {
|
|
26
|
+
Commitment,
|
|
27
|
+
CorpusScope,
|
|
28
|
+
Focus,
|
|
29
|
+
FocusCategory,
|
|
30
|
+
FocusDraft,
|
|
31
|
+
FocusStanding,
|
|
32
|
+
FocusStatus,
|
|
33
|
+
FocusTimeFrame,
|
|
34
|
+
} from './types';
|
|
35
|
+
|
|
36
|
+
/** The entity type key a focus lands under, matching `scripts/schemas/planning.json`. */
|
|
37
|
+
export const FOCUS_TYPE = 'focus';
|
|
38
|
+
|
|
39
|
+
/** The edge types, matching the same payload and the tenant's `capabilities()`. */
|
|
40
|
+
export const IN_FOCUS = 'in_focus';
|
|
41
|
+
export const SUB_FOCUS = 'sub_focus';
|
|
42
|
+
|
|
43
|
+
const FOCUS_STATUSES: readonly FocusStatus[] = [
|
|
44
|
+
'draft', 'active', 'on_track', 'at_risk', 'off_track', 'completed', 'cancelled',
|
|
45
|
+
];
|
|
46
|
+
const FOCUS_CATEGORIES: readonly FocusCategory[] = [
|
|
47
|
+
'milestone', 'financial', 'product', 'team', 'market', 'other',
|
|
48
|
+
];
|
|
49
|
+
const FOCUS_TIME_FRAMES: readonly FocusTimeFrame[] = [
|
|
50
|
+
'Q1', 'Q2', 'Q3', 'Q4', 'H1', 'H2', 'annual', 'custom',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// identity
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `focus:<slug>` from a title.
|
|
59
|
+
*
|
|
60
|
+
* STABLE ACROSS RENAMES is the property that matters, and it is why the slug is
|
|
61
|
+
* taken once at creation and never recomputed: `external_id` is what the
|
|
62
|
+
* tenant's upsert keys on, so deriving it from the CURRENT title would make
|
|
63
|
+
* renaming a focus fork it into a second row with the first one's items still
|
|
64
|
+
* attached to the original.
|
|
65
|
+
*/
|
|
66
|
+
export function focusSlug(title: string): string {
|
|
67
|
+
const slug = title
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
70
|
+
.replace(/^-+|-+$/g, '')
|
|
71
|
+
.slice(0, 60);
|
|
72
|
+
return slug || 'untitled';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A fresh id for an authored item.
|
|
77
|
+
*
|
|
78
|
+
* INJECTABLE, so a test asserts a payload rather than a regex. Falls back to a
|
|
79
|
+
* timestamp-plus-counter when `crypto.randomUUID` is unavailable (older Safari,
|
|
80
|
+
* a non-secure context) — uniqueness within one workspace is all the key needs,
|
|
81
|
+
* because `external_id` is unique per (org, type, owner) and not globally.
|
|
82
|
+
*/
|
|
83
|
+
let authoredCounter = 0;
|
|
84
|
+
export function authoredId(): string {
|
|
85
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
86
|
+
if (uuid) return uuid;
|
|
87
|
+
authoredCounter += 1;
|
|
88
|
+
return `${Date.now().toString(36)}-${authoredCounter.toString(36)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// focus <-> entity
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function bag(entity: TenantEntity): Record<string, unknown> {
|
|
96
|
+
const data = entity.data;
|
|
97
|
+
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pick(data: Record<string, unknown>, ...keys: string[]): unknown {
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
const value = data[key];
|
|
103
|
+
if (value !== undefined && value !== null && value !== '') return value;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function text(value: unknown): string {
|
|
109
|
+
if (typeof value === 'string') return value;
|
|
110
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function num(value: unknown): number | undefined {
|
|
115
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
116
|
+
if (typeof value === 'string' && value.trim()) {
|
|
117
|
+
const parsed = Number(value);
|
|
118
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
|
|
124
|
+
const raw = text(value);
|
|
125
|
+
return (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function toFocus(entity: TenantEntity, fallbackScope: CorpusScope): Focus {
|
|
129
|
+
const data = bag(entity);
|
|
130
|
+
const externalId = text(entity.external_id ?? entity.externalId);
|
|
131
|
+
const orgId = text(entity.org_id ?? entity.orgId) || fallbackScope.orgId;
|
|
132
|
+
const ownerSub = text(entity.owner_sub ?? entity.ownerSub) || fallbackScope.ownerSub || '';
|
|
133
|
+
|
|
134
|
+
const target = num(pick(data, 'target_value', 'targetValue'));
|
|
135
|
+
const current = num(pick(data, 'current_value', 'currentValue'));
|
|
136
|
+
const progress = num(pick(data, 'progress'));
|
|
137
|
+
const year = num(pick(data, 'year'));
|
|
138
|
+
const sortOrder = num(pick(data, 'sort_order', 'sortOrder'));
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id: String(entity.id),
|
|
142
|
+
externalId: externalId || `${FOCUS_TYPE}:${entity.id}`,
|
|
143
|
+
title: text(pick(data, 'title')) || entity.name || 'Untitled focus',
|
|
144
|
+
detail: text(pick(data, 'detail', 'description')),
|
|
145
|
+
// `draft` is the floor rather than `active`: a row whose status did not
|
|
146
|
+
// survive a round trip must not present as work somebody committed to.
|
|
147
|
+
status: oneOf(pick(data, 'status'), FOCUS_STATUSES) ?? 'draft',
|
|
148
|
+
...(oneOf(pick(data, 'category'), FOCUS_CATEGORIES)
|
|
149
|
+
? { category: oneOf(pick(data, 'category'), FOCUS_CATEGORIES) } : {}),
|
|
150
|
+
...(oneOf(pick(data, 'time_frame', 'timeFrame'), FOCUS_TIME_FRAMES)
|
|
151
|
+
? { timeFrame: oneOf(pick(data, 'time_frame', 'timeFrame'), FOCUS_TIME_FRAMES) } : {}),
|
|
152
|
+
...(year !== undefined ? { year } : {}),
|
|
153
|
+
...(pick(data, 'start_date', 'startDate')
|
|
154
|
+
? { startDate: text(pick(data, 'start_date', 'startDate')) } : {}),
|
|
155
|
+
...(pick(data, 'end_date', 'endDate')
|
|
156
|
+
? { endDate: text(pick(data, 'end_date', 'endDate')) } : {}),
|
|
157
|
+
// Spread-if-defined rather than `?? null`, so "no target" stays absent and
|
|
158
|
+
// never becomes a 0 that reads as a fully-achieved measurable focus.
|
|
159
|
+
...(target !== undefined ? { targetValue: target } : {}),
|
|
160
|
+
...(current !== undefined ? { currentValue: current } : {}),
|
|
161
|
+
...(pick(data, 'unit') ? { unit: text(pick(data, 'unit')) } : {}),
|
|
162
|
+
...(progress !== undefined ? { progress } : {}),
|
|
163
|
+
...(sortOrder !== undefined ? { sortOrder } : {}),
|
|
164
|
+
...(pick(data, 'owner_sub', 'ownerSub')
|
|
165
|
+
? { ownerSub: text(pick(data, 'owner_sub', 'ownerSub')) } : {}),
|
|
166
|
+
...(pick(data, 'owner_name', 'ownerName')
|
|
167
|
+
? { ownerName: text(pick(data, 'owner_name', 'ownerName')) } : {}),
|
|
168
|
+
scope: ownerSub ? { orgId, ownerSub } : { orgId },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The write side: a focus draft as the tenant's upsert payload. */
|
|
173
|
+
export function focusToEntity(draft: FocusDraft, externalId?: string): {
|
|
174
|
+
entity_type: string;
|
|
175
|
+
external_id: string;
|
|
176
|
+
name: string;
|
|
177
|
+
data: Record<string, unknown>;
|
|
178
|
+
} {
|
|
179
|
+
const title = draft.title.trim() || 'Untitled focus';
|
|
180
|
+
return {
|
|
181
|
+
entity_type: FOCUS_TYPE,
|
|
182
|
+
external_id: externalId || `${FOCUS_TYPE}:${focusSlug(title)}`,
|
|
183
|
+
name: title.slice(0, 200),
|
|
184
|
+
data: {
|
|
185
|
+
title,
|
|
186
|
+
...(draft.detail ? { detail: draft.detail } : {}),
|
|
187
|
+
status: draft.status ?? 'active',
|
|
188
|
+
...(draft.category ? { category: draft.category } : {}),
|
|
189
|
+
...(draft.timeFrame ? { time_frame: draft.timeFrame } : {}),
|
|
190
|
+
...(draft.year !== undefined && draft.year !== null ? { year: draft.year } : {}),
|
|
191
|
+
...(draft.startDate ? { start_date: draft.startDate } : {}),
|
|
192
|
+
...(draft.endDate ? { end_date: draft.endDate } : {}),
|
|
193
|
+
...(draft.targetValue !== undefined && draft.targetValue !== null
|
|
194
|
+
? { target_value: draft.targetValue } : {}),
|
|
195
|
+
...(draft.currentValue !== undefined && draft.currentValue !== null
|
|
196
|
+
? { current_value: draft.currentValue } : {}),
|
|
197
|
+
...(draft.unit ? { unit: draft.unit } : {}),
|
|
198
|
+
...(draft.ownerSub ? { owner_sub: draft.ownerSub } : {}),
|
|
199
|
+
...(draft.ownerName ? { owner_name: draft.ownerName } : {}),
|
|
200
|
+
[ORIGIN_KEY]: ORIGIN.authored,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// authored items
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A person's own item as the tenant's upsert payload.
|
|
211
|
+
*
|
|
212
|
+
* THE TIMESTAMPS ARE THE POINT OF THIS FUNCTION, not the identity.
|
|
213
|
+
*
|
|
214
|
+
* An extracted item gets `first_seen_at` and `last_seen_at` from the meeting
|
|
215
|
+
* that raised it. An authored one has no meeting — and if those two fields are
|
|
216
|
+
* left empty, the item is not merely undated: it silently disappears from three
|
|
217
|
+
* of the tracker's four tabs. "Changed this week" filters on `last_seen_at`, the
|
|
218
|
+
* age line ("open for 6 days") reads `first_seen_at`, and the server-side
|
|
219
|
+
* `changedAfter` filter is a `attr.last_seen_at__gte` that a null never matches.
|
|
220
|
+
*
|
|
221
|
+
* The whole argument for putting these rows tenant-side is that an extracted
|
|
222
|
+
* item and a hand-authored one appear in ONE list. Three tabs quietly excluding
|
|
223
|
+
* half of it would make that claim false on screen while it stayed true in the
|
|
224
|
+
* database, which is the worst of both.
|
|
225
|
+
*
|
|
226
|
+
* `now` is injectable for the same reason `TrackerPageProps.now` is: a clock
|
|
227
|
+
* read inside a render is a new value every render.
|
|
228
|
+
*/
|
|
229
|
+
export function authoredItemToEntity(
|
|
230
|
+
draft: {
|
|
231
|
+
title: string;
|
|
232
|
+
detail?: string;
|
|
233
|
+
subtype?: string;
|
|
234
|
+
status?: string;
|
|
235
|
+
ownerName?: string;
|
|
236
|
+
ownerSub?: string;
|
|
237
|
+
subject?: string;
|
|
238
|
+
dueAt?: string | null;
|
|
239
|
+
priority?: number;
|
|
240
|
+
},
|
|
241
|
+
options: { id?: string; now?: number; itemType?: string } = {},
|
|
242
|
+
): { entity_type: string; external_id: string; name: string; data: Record<string, unknown> } {
|
|
243
|
+
const title = draft.title.trim();
|
|
244
|
+
const stamp = new Date(options.now ?? Date.now()).toISOString();
|
|
245
|
+
return {
|
|
246
|
+
// The item type key is the historical `commitment`, so an authored row and
|
|
247
|
+
// an extracted one land in the same list — see the payload's own note.
|
|
248
|
+
entity_type: options.itemType ?? 'commitment',
|
|
249
|
+
// `item:` and not `commit:`, so an authored row can never collide with an
|
|
250
|
+
// extracted row's `commit:<dedupeKey>` — and so a re-derivation can tell at
|
|
251
|
+
// a glance which rows it is allowed to rewrite.
|
|
252
|
+
external_id: `item:${options.id ?? authoredId()}`,
|
|
253
|
+
name: title.slice(0, 200),
|
|
254
|
+
data: {
|
|
255
|
+
title,
|
|
256
|
+
detail: draft.detail ?? '',
|
|
257
|
+
subtype: draft.subtype || 'action',
|
|
258
|
+
status: draft.status || 'open',
|
|
259
|
+
...(draft.ownerName ? { owner_name: draft.ownerName } : {}),
|
|
260
|
+
...(draft.ownerSub ? { owner_sub: draft.ownerSub } : {}),
|
|
261
|
+
...(draft.subject ? { subject: draft.subject } : {}),
|
|
262
|
+
...(draft.dueAt ? { due_at: draft.dueAt } : {}),
|
|
263
|
+
...(draft.priority !== undefined ? { priority: draft.priority } : {}),
|
|
264
|
+
first_seen_at: stamp,
|
|
265
|
+
last_seen_at: stamp,
|
|
266
|
+
// No mentions, and the array is written EMPTY rather than omitted: a
|
|
267
|
+
// reader that has to distinguish "authored, so no meeting" from "the key
|
|
268
|
+
// is missing" should not have to guess, and the row renderer branches on
|
|
269
|
+
// exactly this.
|
|
270
|
+
mentions: [],
|
|
271
|
+
mention_count: 0,
|
|
272
|
+
[ORIGIN_KEY]: ORIGIN.authored,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** True when this row was typed by a person rather than read out of a meeting. */
|
|
278
|
+
export function isAuthored(item: Commitment): boolean {
|
|
279
|
+
return item.origin === ORIGIN.authored || item.externalId.startsWith('item:');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// standing — arithmetic only, and nothing that writes
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* How far along a focus is, as a fraction, or `null` when there is nothing to
|
|
288
|
+
* say.
|
|
289
|
+
*
|
|
290
|
+
* TWO WAYS, mirroring `Goal`'s: a MEASURABLE focus (one with a target) is
|
|
291
|
+
* current/target; a qualitative one is done/total over its items. `null` when
|
|
292
|
+
* neither applies, because a focus with no target and no items has no progress —
|
|
293
|
+
* rendering that as 0% says "started and got nowhere" about work nobody has
|
|
294
|
+
* filed anything under yet.
|
|
295
|
+
*
|
|
296
|
+
* PURE, AND IT NEVER WRITES. `Focus.progress` is a STORED field, set by a person
|
|
297
|
+
* or by an explicit recalculation the user asks for. Wiring this into a render
|
|
298
|
+
* effect that PATCHes the result back is the 917-requests-in-ten-minutes loop
|
|
299
|
+
* recorded on `TrackerPageProps.now`, with a write on every pass.
|
|
300
|
+
*/
|
|
301
|
+
export function focusProgress(focus: Focus, standing?: FocusStanding | null): number | null {
|
|
302
|
+
if (focus.targetValue !== undefined && focus.targetValue !== null && focus.targetValue > 0) {
|
|
303
|
+
const current = focus.currentValue ?? 0;
|
|
304
|
+
return Math.max(0, Math.min(current / focus.targetValue, 1));
|
|
305
|
+
}
|
|
306
|
+
if (standing && standing.total > 0) {
|
|
307
|
+
return Math.max(0, Math.min(standing.done / standing.total, 1));
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* What to SHOW for a focus: the stored value when there is one, the derived one
|
|
314
|
+
* otherwise.
|
|
315
|
+
*
|
|
316
|
+
* The stored field wins because it is what the server ORDERS by
|
|
317
|
+
* (`?order=-attr.progress`), and a card that displayed a number the list was not
|
|
318
|
+
* sorted by would make the sort look broken.
|
|
319
|
+
*/
|
|
320
|
+
export function displayProgress(focus: Focus, standing?: FocusStanding | null): number | null {
|
|
321
|
+
if (focus.progress !== undefined && focus.progress !== null) return focus.progress;
|
|
322
|
+
return focusProgress(focus, standing);
|
|
323
|
+
}
|
package/src/corpus/types.ts
CHANGED
|
@@ -511,6 +511,40 @@ export interface Commitment {
|
|
|
511
511
|
actionKey: string;
|
|
512
512
|
/** When it is due, only when the source actually said so. Never invented. */
|
|
513
513
|
dueAt?: string | null;
|
|
514
|
+
/**
|
|
515
|
+
* 0 critical .. 4 backlog — `WorkItem.Priority` verbatim, so ascending is
|
|
516
|
+
* urgent and a triage list needs no per-client mapping table.
|
|
517
|
+
*
|
|
518
|
+
* OPTIONAL AND UNSET IS NOT 2. An extracted item has no priority because
|
|
519
|
+
* nobody assigned one, and defaulting it to "medium" would put a hundred
|
|
520
|
+
* unreviewed rows in the middle of a list somebody is trying to triage.
|
|
521
|
+
*/
|
|
522
|
+
priority?: number;
|
|
523
|
+
/**
|
|
524
|
+
* Which focus this item is filed under, when it is filed at all.
|
|
525
|
+
*
|
|
526
|
+
* Extraction writes items UNFILED, which is what keeps `saveCommitments` a
|
|
527
|
+
* single bulk upsert — filing is a second write (an `in_focus` edge) and an
|
|
528
|
+
* explicitly human act. So `undefined` here is a real and common state, not a
|
|
529
|
+
* loading artefact.
|
|
530
|
+
*/
|
|
531
|
+
focusId?: string;
|
|
532
|
+
/**
|
|
533
|
+
* Who owes it, as a MEMBER — the central user_id behind the subtree roster.
|
|
534
|
+
*
|
|
535
|
+
* ADDITIVE TO `ownerName`, never a replacement (bd startsim-768w.38.29). The
|
|
536
|
+
* name is what the meeting said; this is the join to a person, and it is what
|
|
537
|
+
* makes "assigned to me" a server filter instead of a scan over free text.
|
|
538
|
+
*/
|
|
539
|
+
ownerSub?: string;
|
|
540
|
+
/**
|
|
541
|
+
* Where the row came from: `tenant-extraction`, `human`, `central-pipeline`.
|
|
542
|
+
*
|
|
543
|
+
* Reads and writes the `_origin` key that three writers have used for months,
|
|
544
|
+
* NOT a field called `origin` — see `ORIGIN` in `./entities` for why the
|
|
545
|
+
* underscore is load-bearing and why this arrives from a browser as `Origin`.
|
|
546
|
+
*/
|
|
547
|
+
origin?: string;
|
|
514
548
|
mentions: CommitmentMention[];
|
|
515
549
|
firstSeenAt: string | null;
|
|
516
550
|
lastSeenAt: string | null;
|
|
@@ -536,6 +570,30 @@ export interface CommitmentDraft {
|
|
|
536
570
|
extractor: 'rules' | 'model';
|
|
537
571
|
}
|
|
538
572
|
|
|
573
|
+
/**
|
|
574
|
+
* A trackable thing a PERSON typed, rather than one read out of a meeting.
|
|
575
|
+
*
|
|
576
|
+
* Deliberately not a `Partial<Commitment>`: an authored item has no dedupe key,
|
|
577
|
+
* no mentions and no extractor, and a type that pretended otherwise would let a
|
|
578
|
+
* caller hand the authoring path a half-built extracted row. Everything the
|
|
579
|
+
* mapper needs to invent (identity, timestamps, origin) it invents in one place.
|
|
580
|
+
*/
|
|
581
|
+
export interface AuthoredItem {
|
|
582
|
+
title: string;
|
|
583
|
+
detail?: string;
|
|
584
|
+
/** `action` when the caller does not say — including `requirement`, which is
|
|
585
|
+
* a subtype rather than a model precisely so it costs nothing to add. */
|
|
586
|
+
subtype?: CommitmentSubtype;
|
|
587
|
+
status?: CommitmentStatus;
|
|
588
|
+
ownerName?: string;
|
|
589
|
+
ownerSub?: string;
|
|
590
|
+
subject?: string;
|
|
591
|
+
dueAt?: string | null;
|
|
592
|
+
priority?: number;
|
|
593
|
+
/** File it on creation. Omit to create it unfiled, like an extracted row. */
|
|
594
|
+
focusId?: string;
|
|
595
|
+
}
|
|
596
|
+
|
|
539
597
|
/**
|
|
540
598
|
* A statement in a LATER meeting that something previously outstanding is now
|
|
541
599
|
* settled. Extracted separately from commitments because closing an item is a
|
|
@@ -562,6 +620,18 @@ export interface CommitmentQuery {
|
|
|
562
620
|
dueBefore?: string;
|
|
563
621
|
/** ISO instant over `lastSeenAt` — "what changed since last week". */
|
|
564
622
|
changedAfter?: string;
|
|
623
|
+
/**
|
|
624
|
+
* Only the items filed under this focus (its tenant row id, not its slug).
|
|
625
|
+
*
|
|
626
|
+
* THE SERVER ANSWERS THIS AS AN EDGE WALK, so it reports the filter back as
|
|
627
|
+
* `related_to` rather than `focus`. The client's applied-filter
|
|
628
|
+
* reconciliation has to know that, or the honesty banner announces that the
|
|
629
|
+
* workspace "did not filter by focus" while the server verifiably did — the
|
|
630
|
+
* same class of bug as reading only one spelling of `applied_filters`.
|
|
631
|
+
*/
|
|
632
|
+
focus?: string;
|
|
633
|
+
/** `tenant-extraction` | `human` | ... — filters on the `_origin` attribute. */
|
|
634
|
+
origin?: string;
|
|
565
635
|
search?: string;
|
|
566
636
|
page?: number;
|
|
567
637
|
pageSize?: number;
|
|
@@ -581,9 +651,35 @@ export interface CommitmentApi {
|
|
|
581
651
|
* than a read-modify-write race in this package.
|
|
582
652
|
*/
|
|
583
653
|
saveCommitments?(commitments: Commitment[]): Promise<void>;
|
|
584
|
-
/**
|
|
585
|
-
|
|
586
|
-
|
|
654
|
+
/**
|
|
655
|
+
* A human overriding the extractor. The extractor must never overwrite this.
|
|
656
|
+
*
|
|
657
|
+
* TAKES THE ROW, NOT ITS ID, because the write is a shallow-merging upsert
|
|
658
|
+
* keyed on `external_id` — the only tenant verb that preserves keys the caller
|
|
659
|
+
* did not send. A signature of `(id, patch)` would force every caller into a
|
|
660
|
+
* read-modify-write, which is the race the upsert exists to remove.
|
|
661
|
+
*/
|
|
662
|
+
updateCommitment?(item: Commitment, patch: Partial<Pick<Commitment,
|
|
663
|
+
'status' | 'statusReason' | 'ownerName' | 'ownerSub' | 'dueAt' | 'subtype'
|
|
664
|
+
| 'priority' | 'title' | 'detail'>>): Promise<Commitment>;
|
|
665
|
+
/**
|
|
666
|
+
* A person adding an item nobody said in a meeting.
|
|
667
|
+
*
|
|
668
|
+
* SEPARATE FROM `saveCommitments` ON PURPOSE. That one is the extractor's bulk
|
|
669
|
+
* upsert keyed by `externalId`, and its documented property — one call
|
|
670
|
+
* carrying every row, so the tenant's upsert provides idempotency rather than
|
|
671
|
+
* a read-modify-write race — is exactly what a per-item authoring call would
|
|
672
|
+
* break if it were folded in.
|
|
673
|
+
*/
|
|
674
|
+
createCommitment?(draft: AuthoredItem): Promise<Commitment>;
|
|
675
|
+
/**
|
|
676
|
+
* File an item under a focus, or unfile it with `null`.
|
|
677
|
+
*
|
|
678
|
+
* Its own verb because it writes an EDGE, not a field: there is no transaction
|
|
679
|
+
* around an entity write and a relationship write, so the two stay separately
|
|
680
|
+
* observable rather than pretending to be atomic.
|
|
681
|
+
*/
|
|
682
|
+
fileCommitment?(item: Commitment, focusId: string | null): Promise<void>;
|
|
587
683
|
}
|
|
588
684
|
|
|
589
685
|
/**
|
|
@@ -938,3 +1034,145 @@ export interface CorpusAskApi {
|
|
|
938
1034
|
scope: CorpusScope;
|
|
939
1035
|
}): Promise<SourceDocument>;
|
|
940
1036
|
}
|
|
1037
|
+
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
// focuses — the planning layer above items (bd startsim-8l6ef)
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* `Goal.Status` verbatim, and the whole seven rather than a convenient subset.
|
|
1044
|
+
*
|
|
1045
|
+
* `on_track` / `at_risk` / `off_track` are a HEALTH judgement a person makes and
|
|
1046
|
+
* are NOT derivable from progress — a focus at 40% can be on track in March and
|
|
1047
|
+
* off track in June. Dropping them would push that judgement into prose, where
|
|
1048
|
+
* nothing can filter on it. `cancelled` is not `completed` and must never render
|
|
1049
|
+
* as it, the same distinction `CommitmentStatus` draws between `dropped` and
|
|
1050
|
+
* `done`.
|
|
1051
|
+
*/
|
|
1052
|
+
export type FocusStatus =
|
|
1053
|
+
| 'draft' | 'active' | 'on_track' | 'at_risk' | 'off_track' | 'completed' | 'cancelled';
|
|
1054
|
+
|
|
1055
|
+
/** `Goal.Category` verbatim. */
|
|
1056
|
+
export type FocusCategory =
|
|
1057
|
+
| 'milestone' | 'financial' | 'product' | 'team' | 'market' | 'other';
|
|
1058
|
+
|
|
1059
|
+
/** `Goal.TimeFrame` verbatim. `custom` makes the dates the authority. */
|
|
1060
|
+
export type FocusTimeFrame =
|
|
1061
|
+
| 'Q1' | 'Q2' | 'Q3' | 'Q4' | 'H1' | 'H2' | 'annual' | 'custom';
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Something a team is trying to achieve, that items roll up into.
|
|
1065
|
+
*
|
|
1066
|
+
* The `Goal` analogue, field for field, and that is the whole reuse contract:
|
|
1067
|
+
* the rows live tenant-side under RLS because a central `Goal` cannot FK a
|
|
1068
|
+
* tenant document, but the VOCABULARY is central's, so "at risk" means the same
|
|
1069
|
+
* thing in both planes and a later rollup needs no translation table.
|
|
1070
|
+
*/
|
|
1071
|
+
export interface Focus {
|
|
1072
|
+
id: string;
|
|
1073
|
+
/** `focus:<slug>` — stable across renames, so a re-import converges. */
|
|
1074
|
+
externalId: string;
|
|
1075
|
+
title: string;
|
|
1076
|
+
detail: string;
|
|
1077
|
+
status: FocusStatus;
|
|
1078
|
+
category?: FocusCategory;
|
|
1079
|
+
timeFrame?: FocusTimeFrame;
|
|
1080
|
+
year?: number | null;
|
|
1081
|
+
startDate?: string | null;
|
|
1082
|
+
endDate?: string | null;
|
|
1083
|
+
/**
|
|
1084
|
+
* The numeric target, when there is one. Its ABSENCE is meaningful and is the
|
|
1085
|
+
* switch `Goal.is_measurable` reads: with no target, a focus is qualitative
|
|
1086
|
+
* and its standing comes from its items. Never coerce this to 0 — that turns
|
|
1087
|
+
* every qualitative focus into a fully-achieved measurable one.
|
|
1088
|
+
*/
|
|
1089
|
+
targetValue?: number | null;
|
|
1090
|
+
currentValue?: number | null;
|
|
1091
|
+
unit?: string;
|
|
1092
|
+
/**
|
|
1093
|
+
* 0.0-1.0, STORED rather than aggregated live, exactly as `Goal.progress` is.
|
|
1094
|
+
*
|
|
1095
|
+
* Storing it is what lets the SERVER answer "worst-off focuses first"
|
|
1096
|
+
* (`?order=-attr.progress`). It is set by a person or by an explicit
|
|
1097
|
+
* recalculation — NEVER from a render effect. A component that recomputed and
|
|
1098
|
+
* PATCHed progress while loading is the write-loop that already cost 917
|
|
1099
|
+
* requests in ten minutes on a live tenant; see `TrackerPageProps.now`.
|
|
1100
|
+
*/
|
|
1101
|
+
progress?: number | null;
|
|
1102
|
+
sortOrder?: number;
|
|
1103
|
+
ownerSub?: string;
|
|
1104
|
+
ownerName?: string;
|
|
1105
|
+
/** The parent focus, when this one nests. Mirrors `Goal.parent`. */
|
|
1106
|
+
parentId?: string;
|
|
1107
|
+
scope: CorpusScope;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** What a person types to create a focus. */
|
|
1111
|
+
export interface FocusDraft {
|
|
1112
|
+
title: string;
|
|
1113
|
+
detail?: string;
|
|
1114
|
+
status?: FocusStatus;
|
|
1115
|
+
category?: FocusCategory;
|
|
1116
|
+
timeFrame?: FocusTimeFrame;
|
|
1117
|
+
year?: number | null;
|
|
1118
|
+
startDate?: string | null;
|
|
1119
|
+
endDate?: string | null;
|
|
1120
|
+
targetValue?: number | null;
|
|
1121
|
+
currentValue?: number | null;
|
|
1122
|
+
unit?: string;
|
|
1123
|
+
ownerSub?: string;
|
|
1124
|
+
ownerName?: string;
|
|
1125
|
+
parentId?: string;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Server-side filters for the focus list (CLAUDE.md rule 8). */
|
|
1129
|
+
export interface FocusQuery {
|
|
1130
|
+
status?: FocusStatus | FocusStatus[];
|
|
1131
|
+
category?: FocusCategory;
|
|
1132
|
+
timeFrame?: FocusTimeFrame;
|
|
1133
|
+
year?: number;
|
|
1134
|
+
owner?: string;
|
|
1135
|
+
search?: string;
|
|
1136
|
+
/** `progress` ascending is worst-off first, which is the useful default. */
|
|
1137
|
+
orderBy?: 'progress' | '-progress' | 'sortOrder' | 'title';
|
|
1138
|
+
page?: number;
|
|
1139
|
+
pageSize?: number;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* How a focus is doing, counted rather than aggregated.
|
|
1144
|
+
*
|
|
1145
|
+
* TWO `page_size=1` READS off the paginator's own `count`, which is why there is
|
|
1146
|
+
* no group_by endpoint on the tenant plane: the number is already available, and
|
|
1147
|
+
* a group_by that skipped the readable-attribute gate would leak the
|
|
1148
|
+
* distribution of a redacted field.
|
|
1149
|
+
*/
|
|
1150
|
+
export interface FocusStanding {
|
|
1151
|
+
focusId: string;
|
|
1152
|
+
total: number;
|
|
1153
|
+
done: number;
|
|
1154
|
+
/** Open items whose stated due date has passed. */
|
|
1155
|
+
overdue: number;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* The planning surface's contract.
|
|
1160
|
+
*
|
|
1161
|
+
* Extends `CommitmentApi` rather than sitting beside it because focuses and
|
|
1162
|
+
* items are ONE surface over one list — a screen that could read focuses without
|
|
1163
|
+
* reading their items would be a folder tree with nothing in it.
|
|
1164
|
+
*/
|
|
1165
|
+
export interface PlanningApi extends CommitmentApi {
|
|
1166
|
+
listFocuses(query?: FocusQuery): Promise<CorpusPage<Focus>>;
|
|
1167
|
+
createFocus?(draft: FocusDraft): Promise<Focus>;
|
|
1168
|
+
/** Takes the row for the same reason `updateCommitment` does. */
|
|
1169
|
+
updateFocus?(focus: Focus, patch: Partial<FocusDraft>): Promise<Focus>;
|
|
1170
|
+
/** Children of a focus, one level. The tree is walked a level at a time
|
|
1171
|
+
* because the query layer caps a traversal at `related_depth=2`. */
|
|
1172
|
+
listChildFocuses?(focusId: string): Promise<CorpusPage<Focus>>;
|
|
1173
|
+
/** Counts for one focus, from `count` alone — no row bodies cross the wire. */
|
|
1174
|
+
focusStanding?(focusId: string, now?: number): Promise<FocusStanding>;
|
|
1175
|
+
/** The tenant's real members, for the owner picker. Absent when the fork is
|
|
1176
|
+
* not wired to central, in which case owners stay free text. */
|
|
1177
|
+
listMembers?(): Promise<Array<{ sub: string; email: string; name?: string }>>;
|
|
1178
|
+
}
|