@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,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
+ }
@@ -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
- /** A human overriding the extractor. The extractor must never overwrite this. */
585
- updateCommitment?(id: string, patch: Partial<Pick<Commitment,
586
- 'status' | 'statusReason' | 'ownerName' | 'dueAt' | 'subtype'>>): Promise<Commitment>;
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
+ }