@opencxh/domain 1.229.0 → 1.232.0

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,4 @@
1
+ export * from './keys';
2
+ export * from './levels';
3
+ export * from './progress';
4
+ export * from './types';
@@ -0,0 +1,32 @@
1
+ import { StrategyItem } from './types';
2
+ /**
3
+ * The scope kind this layer claims, and the key everything else points at it with.
4
+ *
5
+ * In `domain` and not in the app because it is a contract: the client builds the key to open a
6
+ * picker, the server authorises it, and the work item that contributes to a goal carries it in
7
+ * its own `keys` column. Two places spelling the prefix separately drift apart on the first typo,
8
+ * and the symptom is "no access to this scope" rather than an error that says what is wrong.
9
+ *
10
+ * `strategy_item` and not `goal`, deliberately: a focus area is a row in this table too, and a
11
+ * key that calls it a goal would be wrong on a third of the rows.
12
+ */
13
+ export declare const STRATEGY_ITEM_SCOPE_KIND = "strategy_item";
14
+ export declare function strategyItemScopeKey(itemId: string): string;
15
+ /**
16
+ * A strategy item's file keys: itself and its ancestors.
17
+ *
18
+ * Rides along on every authorisation, so it stays small — the same trade-off `itemDossierKeys`
19
+ * makes. The ancestors are in it because that is what lets a note written on a key result be
20
+ * found from the area above it; the *children* are not, because a top-level area has unbounded
21
+ * descendants and this array would grow with the org.
22
+ */
23
+ export declare function strategyDossierKeys(item: Pick<StrategyItem, "id" | "ancestorKeys">, limit?: number): string[];
24
+ /**
25
+ * The ancestor chain of a child, from its parent's.
26
+ *
27
+ * Top to bottom, so "everything under the area" and "everything under the objective" are both one
28
+ * `$in` on the same column. No cycle detection here: a row cannot become its own ancestor while
29
+ * the move route refuses to hang one under its own descendant, and that is where the check
30
+ * belongs — here it would run on every create for a case that does not exist.
31
+ */
32
+ export declare function strategyAncestorKeysFor(parent: Pick<StrategyItem, "id" | "ancestorKeys"> | null): string[];
@@ -0,0 +1,73 @@
1
+ import { StrategyHealth, StrategyHealthCategory } from './types';
2
+ /**
3
+ * The ladder, and the two vocabularies read off it.
4
+ *
5
+ * Same split as `ladder.ts` + `item-types.ts` in work: a fixed set that is ours, plus the
6
+ * resolution that decides what applies. The difference is that a level carries something a work
7
+ * type does not — an **order** — because the one structural rule in this model is about depth.
8
+ */
9
+ /** One rung. */
10
+ export interface StrategyLevel {
11
+ key: string;
12
+ /** An i18n key for the fixed three; a name somebody typed for anything else. */
13
+ label: string;
14
+ /**
15
+ * Rank, not depth. A parent's order must be at most the child's, so gaps of ten leave room for
16
+ * an org to slot a rung in between later without renumbering what exists.
17
+ */
18
+ order: number;
19
+ /**
20
+ * Does this rung carry a measure?
21
+ *
22
+ * It steers the form and nothing else — no query branches on it. A measured area is odd but not
23
+ * wrong, and refusing it here would mean a validation error for something harmless.
24
+ */
25
+ measurable: boolean;
26
+ /** Lucide icon name. */
27
+ icon?: string;
28
+ }
29
+ /**
30
+ * The three rungs that are **ours**.
31
+ *
32
+ * Labels are i18n keys, for the reason `LOOSE_STATUSES` spells out: a literal string here fixes
33
+ * the language at module load.
34
+ *
35
+ * Three and not more, on purpose. Atlassian Focus lets an admin define several *area* levels
36
+ * (Market → Business unit → Group); here that is the same rung nested under itself, which
37
+ * {@link canParent} allows. One key means one meaning in every report, which a per-org level per
38
+ * customer would not.
39
+ */
40
+ export declare const FIXED_LEVELS: readonly StrategyLevel[];
41
+ /** The order an unknown level sorts at: below everything declared. */
42
+ export declare const UNKNOWN_LEVEL_ORDER = 999;
43
+ /** How often a check-in is expected when the row does not say. */
44
+ export declare const DEFAULT_CHECK_IN_DAYS = 7;
45
+ /** The rung with this key, or `undefined`. */
46
+ export declare function levelIn(levelKey: string, levels?: readonly StrategyLevel[]): StrategyLevel | undefined;
47
+ /**
48
+ * The rank of a level key — the sort key of the whole tree.
49
+ *
50
+ * An unknown key ranks last rather than first, the same direction `cycleOrder` chose: something
51
+ * nobody has classified belongs after everything that is classified, not on top of it.
52
+ */
53
+ export declare function levelOrder(levelKey: string, levels?: readonly StrategyLevel[]): number;
54
+ /**
55
+ * May a row of `childLevel` hang under one of `parentLevel`?
56
+ *
57
+ * One predicate instead of a table of allowed pairs. Equal is allowed and that is the point: it
58
+ * is what gives an org several area levels without a second concept. An unknown level ranks last,
59
+ * so it can sit under anything and nothing can sit under it — which is the safe direction for a
60
+ * key this platform does not recognise.
61
+ */
62
+ export declare function canParent(childLevelKey: string, parentLevelKey: string, levels?: readonly StrategyLevel[]): boolean;
63
+ /**
64
+ * Which of our own rungs a source's name means, or `null`.
65
+ *
66
+ * On the **name**, like `fixedTypeForName`: an id is per site, a name is what a person
67
+ * recognises. Nothing here guesses — a level this platform does not know keeps its own key under
68
+ * its own name, which is the honest outcome.
69
+ */
70
+ export declare function fixedLevelForName(name: string): string | null;
71
+ export declare function healthCategory(health: StrategyHealth): StrategyHealthCategory;
72
+ /** Every health value in one bucket — what a `healthCategories` filter expands to. */
73
+ export declare function healthsInCategory(categories: readonly StrategyHealthCategory[]): StrategyHealth[];
@@ -0,0 +1,77 @@
1
+ import { StrategyHealth, StrategyItem, StrategyMeasure, StrategyProgress } from './types';
2
+ /**
3
+ * How far along a measurement is, 0..1.
4
+ *
5
+ * Measured from `startValue`, not from zero: "from 5h30m down to 2h" is 0% on day one, while a
6
+ * zero baseline would report that same goal as already two-thirds done before anyone did
7
+ * anything. `direction` is what makes the falling case work at all.
8
+ *
9
+ * When target and start are equal there is no distance to travel, so the ratio is the answer to
10
+ * "are we there": 1 if the current value has reached the target in the intended direction,
11
+ * otherwise 0. Dividing would be `Infinity` or `NaN`, and both render as a full bar.
12
+ */
13
+ export declare function measureRatio(measure: Pick<StrategyMeasure, "direction" | "startValue" | "targetValue">, currentValue: number): number;
14
+ /**
15
+ * Where a linear pace would stand now, 0..1.
16
+ *
17
+ * `undefined` without both dates, and that absence is the contract: a goal with no window has no
18
+ * expected pace, and defaulting to "now over target" would invent one. A window whose end is not
19
+ * after its start is refused for the same reason — it cannot state a pace, only a division.
20
+ */
21
+ export declare function paceRatio(item: Pick<StrategyItem, "startDate" | "targetDate">, now: number): number | undefined;
22
+ /** How far behind the expected pace counts as "at risk" rather than "on track". */
23
+ export declare const AT_RISK_GAP = 0.1;
24
+ /** And how far behind counts as off track. */
25
+ export declare const OFF_TRACK_GAP = 0.25;
26
+ /**
27
+ * What the numbers would say, if they got a vote.
28
+ *
29
+ * **A suggestion, never a write.** It fills the pre-selected chip in the check-in dialog and
30
+ * nothing else; `health` is only ever set by the person checking in. A goal that is behind
31
+ * because the work lands in December is on track, and no formula knows that.
32
+ *
33
+ * `undefined` when there is nothing to compare against — no ratio, or no pace. A hint with no
34
+ * basis is worse than no hint.
35
+ */
36
+ export declare function suggestedHealth(ratio?: number, pace?: number): StrategyHealth | undefined;
37
+ /**
38
+ * A parent's ratio, from its children.
39
+ *
40
+ * The mean of the children that **have** a ratio, and the filter is the whole point: an area
41
+ * usually holds a measured objective beside an unmeasured sub-area, and counting the latter as
42
+ * zero would park every area at half of what it is really doing.
43
+ *
44
+ * `undefined` when no child carries a measurement — the honest answer, which the screen renders
45
+ * as "no measurement yet" rather than an empty bar at 0%.
46
+ *
47
+ * Unweighted on purpose. Weighting by anything available here (child count, estimate, level)
48
+ * would be us deciding which bet matters more; an org that wants that says it by splitting the
49
+ * goal.
50
+ */
51
+ /**
52
+ * The share of a linked work set that is done, 0..1.
53
+ *
54
+ * `undefined` on an empty set, and that is the whole reason this is not `done / total` at the
55
+ * call site: nothing linked yet is "no measurement", while `0/0` is `NaN` and a hand-written
56
+ * guard would render it as an honest-looking 0%.
57
+ */
58
+ export declare function workRatio(done: number, total: number): number | undefined;
59
+ export declare function rollupChildren(children: readonly {
60
+ progress?: Pick<StrategyProgress, "ratio">;
61
+ }[]): number | undefined;
62
+ /**
63
+ * Is a check-in overdue, and by how much?
64
+ *
65
+ * Counted from the last check-in, or from creation when there has never been one — a goal created
66
+ * three weeks ago and never touched is exactly the one worth nudging. Returns `0` when it is not
67
+ * due yet, so a caller can sort on lateness without a second branch.
68
+ */
69
+ export declare function checkInOverdueMs(item: Pick<StrategyItem, "checkInEveryDays" | "lastCheckInAt" | "createdAt">, now: number): number;
70
+ /**
71
+ * The sort key of a strategy item's window: its target date, or the far end of time.
72
+ *
73
+ * Same shape and same direction as `cycleOrder`: a row nobody has put a date on belongs *after*
74
+ * everything that is planned, and `?? 0` would put it on top. Never a bare subtraction — one
75
+ * `NaN` from a comparator makes `Array.prototype.sort` implementation-defined for every row.
76
+ */
77
+ export declare function targetOrder(item: Pick<StrategyItem, "targetDate">): number;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,227 @@
1
+ import { MutationAuthor } from '../../platform/author';
2
+ import { ReportFilters } from '../analytics/report';
3
+ import { OwnerScope } from '../scope/types';
4
+ import { WorkItemSource } from '../work/types';
5
+ /**
6
+ * Strategy: the layer above execution — what we are betting on, and whether we are getting there.
7
+ *
8
+ * One entity for a focus area, an objective and a key result, for the same reason `WorkItem` is
9
+ * one entity for a task, a case and a deal: they differ in *words*, not in behaviour. Hierarchy,
10
+ * health, check-ins, linking, access and rollup are identical on all three; the only real
11
+ * difference is that an area leaves {@link StrategyItem.measure} empty. That is a field, not a
12
+ * second table.
13
+ *
14
+ * The rule that bounds this model, in three questions:
15
+ *
16
+ * 1. *Is it a bet, or the work that pays it off?* A bet → a `StrategyItem`. The work → a
17
+ * `WorkItem`, linked here, never copied here.
18
+ * 2. *Is the number judged, or counted?* Judged → {@link StrategyItem.health}, written by a
19
+ * person in a check-in. Counted → {@link StrategyItem.progress}, written only by the rollup.
20
+ * Nothing writes both.
21
+ * 3. *Is the answer a number this platform can already produce?* Then it is a
22
+ * {@link MeasureSource}, not a new column — every app that reports to analytics is a source.
23
+ */
24
+ /**
25
+ * Where something stands, as a person declares it.
26
+ *
27
+ * Deliberately **not** derived from {@link StrategyProgress}. A goal at 40% halfway through the
28
+ * quarter can be perfectly on track (the work lands in December) or already lost (the team was
29
+ * reassigned), and no formula knows which. `suggestedHealth` offers an opinion in the check-in
30
+ * dialog; the owner decides.
31
+ *
32
+ * `achieved` and `missed` are two values rather than one `closed`, and that is the one place this
33
+ * model deviates from `WorkResolution` — which is a free label with no outcome flag, at the stated
34
+ * price that "nothing can compute how much ended well". Answering exactly that is why a goal
35
+ * exists, so the flag belongs here.
36
+ */
37
+ export type StrategyHealth = "pending" | "on_track" | "at_risk" | "off_track" | "paused" | "achieved" | "missed";
38
+ /**
39
+ * The three buckets every list, counter and report asks for.
40
+ *
41
+ * Same shape and the same reason as `WorkStatusCategory`: the question "is this still running"
42
+ * must be answerable without a configuration lookup.
43
+ */
44
+ export type StrategyHealthCategory = "open" | "paused" | "closed";
45
+ /** How a measured value is read and formatted. */
46
+ export type MeasureUnit = "number" | "percent" | "currency" | "duration_ms";
47
+ /**
48
+ * Which way is good.
49
+ *
50
+ * Without this a falling number reads as failure, and "bring the first reply time back under two
51
+ * hours" is exactly the goal a team sets first.
52
+ */
53
+ export type MeasureDirection = "up" | "down";
54
+ /**
55
+ * Where the current value comes from.
56
+ *
57
+ * Four kinds and no fifth: anything else a customer wants to measure is already a metric in the
58
+ * analytics catalogue, and that is what `metric` is for.
59
+ */
60
+ export type MeasureSource =
61
+ /** Somebody types the number at a check-in. The floor — works with no other app installed. */
62
+ {
63
+ kind: "manual";
64
+ }
65
+ /** The mean of the children that have a ratio. See `rollupChildren`. */
66
+ | {
67
+ kind: "children";
68
+ }
69
+ /**
70
+ * The share of the linked work that is done.
71
+ *
72
+ * The set is not stored here: it is the relations on this item's scopeKey, which land in
73
+ * `WorkItem.keys` and make "everything under this goal" one indexed query. `count` says what a
74
+ * unit is — one item, or one second of estimated effort. The second is what makes a goal about
75
+ * a big epic and twenty small chores mean anything.
76
+ */
77
+ | {
78
+ kind: "work";
79
+ count: "items" | "estimate";
80
+ }
81
+ /**
82
+ * A counter from the analytics catalogue — this is what makes the layer generic: every app
83
+ * that is already an `analytics-source` becomes a goal source with no code.
84
+ *
85
+ * `window` says which range to count over: `target` is the goal's own start→target window,
86
+ * `rolling_30d` the last thirty days (for a goal about a *rate* that should hold, not a total
87
+ * that should accumulate).
88
+ */
89
+ | {
90
+ kind: "metric";
91
+ metricId: string;
92
+ filters?: ReportFilters;
93
+ window: "target" | "rolling_30d";
94
+ };
95
+ /** What "achieved" means as a number. Absent on a row that carries no measurement. */
96
+ export interface StrategyMeasure {
97
+ unit: MeasureUnit;
98
+ direction: MeasureDirection;
99
+ /**
100
+ * Where we started, and where we are going.
101
+ *
102
+ * **Both are absent for a `work` source, and that is the point.** What "done" means there is
103
+ * the linked set itself — link a fourteenth item and the target is fourteen. A typed target
104
+ * beside a set that moves is two answers to one question, and the stored one goes stale
105
+ * silently. For `manual` and `children` they are required, enforced on the write path.
106
+ *
107
+ * Not defaulted to zero either: "from 5h30m to 2h" is 0% at the start, while a zero baseline
108
+ * would report that same goal as already two-thirds done on day one.
109
+ */
110
+ startValue?: number;
111
+ targetValue?: number;
112
+ source: MeasureSource;
113
+ }
114
+ /**
115
+ * The outcome of the last rollup. **Never written by a person** — the check-in writes
116
+ * `currentValue` only when the source is `manual`, and even then through the same path.
117
+ */
118
+ export interface StrategyProgress {
119
+ currentValue: number;
120
+ /** 0..1, clamped. */
121
+ ratio: number;
122
+ /** Where a linear pace would stand now, 0..1. Absent without both dates — no invented pace. */
123
+ paceRatio?: number;
124
+ /**
125
+ * The counted set, for a `work` source only.
126
+ *
127
+ * Kept beside `ratio` so the screen can say "12 of 20 done" instead of "60%": the fraction is
128
+ * what was actually counted, and the percentage is the derived thing.
129
+ */
130
+ done?: number;
131
+ total?: number;
132
+ computedAt: number;
133
+ /**
134
+ * The last rollup failed for this row; the value above is stale.
135
+ *
136
+ * A separate field rather than a silent old number: a figure with no date on it is the one
137
+ * thing a strategy screen must not show.
138
+ */
139
+ problem?: string;
140
+ }
141
+ /**
142
+ * A strategy item: a focus area, an objective or a key result.
143
+ *
144
+ * Note what is **not** here: no `workItemIds`, no `focusAreaIds`. Everything this row points at
145
+ * runs through the platform's relation store, and a work item linked here carries
146
+ * `strategy_item:<id>` in its own `keys` — so "the work under this goal" is one indexed `$in`
147
+ * in the app that owns the work, not a join this store cannot do.
148
+ */
149
+ export interface StrategyItem {
150
+ id: string;
151
+ organizationId: string;
152
+ /** A rung of the ladder — see `FIXED_LEVELS`. An unknown key sorts to the bottom. */
153
+ levelKey: string;
154
+ name: string;
155
+ description?: string;
156
+ /** Lucide icon name. Absent = the reader picks one from the level. */
157
+ icon?: string;
158
+ color?: string;
159
+ /** The gate, in exactly `WorkProject`'s shape so one predicate serves both. */
160
+ ownerScope: OwnerScope;
161
+ memberUserIds?: string[];
162
+ memberTeamIds?: string[];
163
+ /**
164
+ * The one accountable person, apart from who may read it.
165
+ *
166
+ * Separate from `ownerScope` on purpose: an org-wide goal is visible to everybody and is still
167
+ * somebody's to answer for. This is also what the check-in reminder is addressed to.
168
+ */
169
+ ownerUserId?: string;
170
+ parentId?: string;
171
+ /**
172
+ * The scopeKeys of all ancestors, flattened at write time, top to bottom.
173
+ *
174
+ * The same mechanism as `WorkItem.ancestorKeys` and for the same reason: this store cannot
175
+ * recurse, so "everything under this area" has to be one indexed `$in`. The price is the same
176
+ * too — moving a row rewrites its whole subtree, and that is a job, not a request.
177
+ */
178
+ ancestorKeys?: string[];
179
+ /**
180
+ * Epoch ms. Both optional: an imported area may genuinely have no window, and inventing one
181
+ * would make up the fact the row exists to state. Never sort these with a bare `a - b`.
182
+ */
183
+ startDate?: number;
184
+ targetDate?: number;
185
+ health: StrategyHealth;
186
+ /**
187
+ * How often a check-in is expected, in days. Absent = `DEFAULT_CHECK_IN_DAYS`.
188
+ *
189
+ * A number rather than a cron-ish rule: the only question anything asks is "is it overdue",
190
+ * and that is `lastCheckInAt + days`.
191
+ */
192
+ checkInEveryDays?: number;
193
+ lastCheckInAt?: number;
194
+ measure?: StrategyMeasure;
195
+ progress?: StrategyProgress;
196
+ archived?: boolean;
197
+ order?: number;
198
+ /** Ids of this row in source systems (`atlassian-goal:<ari>`). Empty on one created here. */
199
+ externalIds?: string[];
200
+ /** Where it came from, in the shape a work item carries. Only the landing writes it. */
201
+ source?: WorkItemSource;
202
+ createdBy: string;
203
+ createdAt?: number;
204
+ updatedAt?: number;
205
+ }
206
+ /**
207
+ * One check-in: the rhythm the whole layer runs on.
208
+ *
209
+ * Its own table and not a `work_activity` row — that column means a *work item* id, and two id
210
+ * spaces in one indexed column is the kind of confusion that surfaces months later as the wrong
211
+ * rows in a feed.
212
+ *
213
+ * `value` doubles as the history series: six check-ins are six points, with no separate snapshot
214
+ * job. That is the same call `WorkCycleSummary` makes — write the number when it is stated, not
215
+ * every night.
216
+ */
217
+ export interface StrategyUpdate {
218
+ id: string;
219
+ organizationId: string;
220
+ itemId: string;
221
+ health: StrategyHealth;
222
+ note?: string;
223
+ /** The measured value at that moment. Absent when the row carries no measure. */
224
+ value?: number;
225
+ author: MutationAuthor;
226
+ createdAt?: number;
227
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './activity';
2
2
  export * from './attention';
3
+ export * from './item-types';
3
4
  export * from './keys';
4
5
  export * from './ladder';
5
6
  export * from './types';
@@ -0,0 +1,59 @@
1
+ import { WorkItemType, WorkProject } from './types';
2
+ /**
3
+ * Item kinds — the same shape as the status ladder, and for the same reason.
4
+ *
5
+ * A kind used to be a key out of a fixed set of four, with `WorkProject.typeKeys` narrowing which
6
+ * of them a project offered. That works until a project's kinds come from somewhere else: a Jira
7
+ * project has Story, Epic and whatever the customer invented, and a bare `string[]` has nowhere
8
+ * to put what they are called. Everything not recognisably a bug flattened onto "task".
9
+ *
10
+ * So a kind is now `{ key, label }` per project, exactly like a status — and the two halves of
11
+ * this file are the two halves of `ladder.ts`: the fixed set that is ours, and the resolution that
12
+ * decides which set applies.
13
+ */
14
+ /**
15
+ * The kinds that are **ours**, available to a project that declares none and to loose work.
16
+ *
17
+ * Their labels are **i18n keys, not text**, for the reason `LOOSE_STATUSES` spells out: a literal
18
+ * string here fixes the language at module level. A kind that came from a source carries a name
19
+ * somebody typed instead, and {@link labelOfType} is what keeps those apart.
20
+ *
21
+ * These keys are **reserved**: a source-defined kind that happens to be called "Bug" resolves onto
22
+ * `bug` rather than minting a second key for the same idea, which is what keeps a cross-project
23
+ * filter from listing "Bug" three times. Anything a source calls something else gets a key of its
24
+ * own, namespaced by project.
25
+ */
26
+ export declare const FIXED_TYPES: readonly WorkItemType[];
27
+ /** The keys {@link FIXED_TYPES} occupies. A project-defined kind may not claim one. */
28
+ export declare const RESERVED_TYPE_KEYS: readonly string[];
29
+ /**
30
+ * The kinds that apply to this item.
31
+ *
32
+ * A project *without* kinds falls back to ours instead of yielding an empty list — the same
33
+ * reasoning as `ladderFor`: a picker with zero options is a dead end, and a half-created project
34
+ * should not be unusable.
35
+ */
36
+ export declare function typesFor(project?: WorkProject | null): readonly WorkItemType[];
37
+ /** The kind with this key, within a set. `undefined` on a key the set does not have. */
38
+ export declare function typeIn(typeKey: string, types: readonly WorkItemType[]): WorkItemType | undefined;
39
+ /**
40
+ * Which of our own kinds a source's name means, or `null` when it means none of them.
41
+ *
42
+ * Read on the **name** and not on an id, because an id is per site and a name is what a person
43
+ * recognises. A site that renamed "Bug" to "Defect" still gets `bug`; one that invented "Onderhoud"
44
+ * gets nothing from here and keeps its own kind under its own name, which is the honest outcome —
45
+ * guessing would put maintenance work in the bug list.
46
+ *
47
+ * Deliberately no `deal`: no source this platform talks to has the concept, and producing one from
48
+ * a name would be a claim about the work rather than a translation of it.
49
+ */
50
+ export declare function fixedTypeForName(name: string): string | null;
51
+ /**
52
+ * Some of {@link FIXED_TYPES}, by key, in the order given.
53
+ *
54
+ * For a caller that wants to *narrow* to a few of our own kinds — a project template saying "this
55
+ * one is about deals". That narrowing is all `typeKeys: string[]` could ever express, and it is
56
+ * still worth expressing; what it could not do was carry a kind that is not ours. An unknown key
57
+ * is dropped rather than invented.
58
+ */
59
+ export declare function fixedTypes(...keys: string[]): WorkItemType[];
@@ -0,0 +1 @@
1
+ export {};
@@ -18,6 +18,15 @@ export declare const WORK_PROJECT_SCOPE_KIND = "work_project";
18
18
  * comms' `activity:` inherits its conversation's: a comment is not a second boundary.
19
19
  */
20
20
  export declare const WORK_ACTIVITY_SCOPE_KIND = "work_activity";
21
+ /**
22
+ * The landing kind for a cycle — and **deliberately not a scope kind**.
23
+ *
24
+ * It names one thing only: which app a `SyncRecord` of this sort belongs to. A cycle is never a
25
+ * `scopeKey`, is never authorised, linked, read as text or given memory; it is a column value on
26
+ * an item. This constant exists so that string is not written out by hand in two apps, not as a
27
+ * first step towards claiming the kind. There is no `workCycleScopeKey`, and there should not be.
28
+ */
29
+ export declare const WORK_CYCLE_SYNC_KIND = "work_cycle";
21
30
  export declare function workItemScopeKey(itemId: string): string;
22
31
  export declare function workProjectScopeKey(projectId: string): string;
23
32
  export declare function workActivityScopeKey(activityId: string): string;
@@ -58,6 +58,25 @@ export interface WorkResolution {
58
58
  label: string;
59
59
  order: number;
60
60
  }
61
+ /**
62
+ * An item kind: an icon with a label, never a workflow.
63
+ *
64
+ * The same shape as {@link WorkStatus} minus the category, because a kind carries no meaning a
65
+ * query can act on — nothing counts by kind the way `categoryOf` counts by status. `label` is an
66
+ * i18n key for one of `FIXED_TYPES` and a name somebody typed for anything else; `labelOfType`
67
+ * is what tells them apart.
68
+ */
69
+ export interface WorkItemType {
70
+ key: string;
71
+ label: string;
72
+ /**
73
+ * A hint for the glyph, for a kind that came from a source.
74
+ *
75
+ * Absent = the reader picks one from the key, which is right for ours. Deliberately not a URL:
76
+ * a vendor's icon lives behind that vendor's auth, and a broken image is worse than a shape.
77
+ */
78
+ icon?: string;
79
+ }
61
80
  /**
62
81
  * A project: the carrier of a work process.
63
82
  *
@@ -98,8 +117,13 @@ export interface WorkProject {
98
117
  statuses: WorkStatus[];
99
118
  /** Resolutions, free per project. Empty = closing asks for no reason. */
100
119
  resolutions?: WorkResolution[];
101
- /** Which item types this project offers. Empty = all of them. */
102
- typeKeys?: string[];
120
+ /**
121
+ * The kinds this project offers, with their names. Empty = the fixed set.
122
+ *
123
+ * Replaced `typeKeys: string[]`, which could only *narrow* a fixed list and had nowhere to put
124
+ * a label — so a project whose kinds come from elsewhere lost every name it had.
125
+ */
126
+ types?: WorkItemType[];
103
127
  /** Keys of the `CustomFieldDef`s enabled on this project, in display order. */
104
128
  fieldKeys?: string[];
105
129
  /** Where a new item starts. Absent = the first status in `order`. */
@@ -119,6 +143,14 @@ export interface WorkProject {
119
143
  * pipeline. Empty on a project created here.
120
144
  */
121
145
  externalIds?: string[];
146
+ /**
147
+ * Where this project came from, in the same shape an item carries.
148
+ *
149
+ * Only the `connectionId` half is load-bearing here: creating an item or a cycle *inside* an
150
+ * imported project has to be written back with the credential that project arrived on, and
151
+ * `externalIds` says which system but not which link.
152
+ */
153
+ source?: WorkItemSource;
122
154
  createdBy: string;
123
155
  createdAt?: number;
124
156
  updatedAt?: number;
@@ -145,9 +177,17 @@ export interface WorkCycle {
145
177
  name: string;
146
178
  /** One sentence. What this cycle is for. */
147
179
  goal?: string;
148
- /** Planned window, epoch ms. What it says it will be, not what it was. */
149
- startDate: number;
150
- endDate: number;
180
+ /**
181
+ * Planned window, epoch ms. What it says it will be, not what it was.
182
+ *
183
+ * **Optional, because an imported one may genuinely not have it.** A Jira sprint that has not
184
+ * started yet carries no dates at all, and inventing a window would be making up the one fact
185
+ * this row exists to state. Sort with {@link cycleOrder}, never with a bare subtraction: `a -
186
+ * b` over an absent date yields `NaN`, and a comparator that returns `NaN` makes
187
+ * `Array.prototype.sort` implementation-defined — it reorders the *dated* rows too.
188
+ */
189
+ startDate?: number;
190
+ endDate?: number;
151
191
  /**
152
192
  * When it actually began and ended, epoch ms.
153
193
  *
@@ -195,6 +235,14 @@ export interface WorkCycleSummary {
195
235
  }
196
236
  /** Where a cycle stands. Derived, because the two timestamps already say it. */
197
237
  export type WorkCycleState = "planned" | "active" | "completed";
238
+ /**
239
+ * The sort key of a cycle: its start, or the far end of time when it has none.
240
+ *
241
+ * One function rather than a `?? 0` per call site, and the direction is deliberate. A cycle with
242
+ * no dates yet is one nobody has planned into the calendar, so it belongs *after* everything that
243
+ * is planned — `?? 0` would put it on top, which is exactly backwards.
244
+ */
245
+ export declare function cycleOrder(cycle: Pick<WorkCycle, "startDate">): number;
198
246
  export declare function cycleState(cycle: Pick<WorkCycle, "startedAt" | "completedAt">): WorkCycleState;
199
247
  export type WorkPriority = "low" | "normal" | "high" | "urgent";
200
248
  /** Where an item came from — a person or something automatic. */
@@ -202,6 +250,18 @@ export interface WorkItemSource {
202
250
  initiator: "user" | "system";
203
251
  /** Free-form reason with `system` ("missed_call", "stale_interaction"). */
204
252
  systemReason?: string;
253
+ /**
254
+ * This row is a **headline index**, not a copy: its truth stays at the source.
255
+ *
256
+ * Absent = an ordinary row, which is what every row made before this field existed is. Set by
257
+ * the landing and only by the landing; see `apps/work/server/src/item/proxy.ts`.
258
+ */
259
+ mode?: "proxy";
260
+ /**
261
+ * → `SyncConnection.id`. Which connection, not just which product: it carries the credential,
262
+ * and two links to the same Jira are two different answers.
263
+ */
264
+ connectionId?: string;
205
265
  }
206
266
  /**
207
267
  * A work item.
@@ -258,6 +318,17 @@ export interface WorkItem {
258
318
  * Only root items carry one; a subtask follows its parent.
259
319
  */
260
320
  cycleId?: string;
321
+ /**
322
+ * The cycle this item is in **at the source**, for an imported item.
323
+ *
324
+ * Stored whether or not it resolved, exactly like {@link WorkItem.parentExternalId}: an issue
325
+ * can land before its sprint does, and the sprint's own landing then adopts it.
326
+ *
327
+ * Unlike the parent, the resolution **overwrites**. A parent is ours to keep once set ("a
328
+ * source must not re-hang work somebody organised here"); a sprint is the source's to move, so
329
+ * filling in only when empty would freeze an item in the sprint it was in a year ago.
330
+ */
331
+ cycleExternalId?: string;
261
332
  /**
262
333
  * The id of the parent **at the source**, for an imported item.
263
334
  *