@opencxh/domain 1.232.0 → 1.233.1

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,42 @@
1
+ /**
2
+ * How many hours somebody is available in a week.
3
+ *
4
+ * An employment fact, not a planning one — it changes because a contract changes, and it has
5
+ * readers outside planning waiting: overtime is booked time above these hours, utilisation is
6
+ * billable divided by them, and leave accrual is a function of them.
7
+ *
8
+ * **It lives in `apps/planning` on purpose, and temporarily.** Three things keep the eventual move
9
+ * to an HR app a hostname rather than a project: this type is in `domain` and not in the app; the
10
+ * app keeps it in one resource folder with no terms logic scattered elsewhere; and every reader
11
+ * reads it over HTTP, which is the only option anyway since apps share no store.
12
+ */
13
+ export interface EmploymentTerms {
14
+ id: string;
15
+ organizationId: string;
16
+ userId: string;
17
+ /**
18
+ * Contract hours, as minutes per week.
19
+ *
20
+ * **Known limit:** a Dutch contract is often "36 hours averaged over 13 weeks". This field
21
+ * cannot say that. An averaging period is one field more the day somebody needs it; it is
22
+ * written down here so it is not a surprise.
23
+ */
24
+ weeklyMinutes: number;
25
+ /** From when this holds. A changed contract is a new row, never a patch on the old one. */
26
+ effectiveFrom?: number;
27
+ note?: string;
28
+ createdBy: string;
29
+ createdAt?: number;
30
+ updatedAt?: number;
31
+ }
32
+ /**
33
+ * The terms that applied to somebody on a given day.
34
+ *
35
+ * The most recent `effectiveFrom` that had already started wins, so the row that governed last
36
+ * quarter stays readable next to the weeks it governed.
37
+ *
38
+ * Deliberately no `fte`: that is `weeklyMinutes` divided by a full-time norm which differs per
39
+ * organisation — forty hours here, thirty-six elsewhere. Storing it would be a second truth;
40
+ * showing it can wait until there is a norm to divide by.
41
+ */
42
+ export declare function termsFor(terms: readonly EmploymentTerms[], userId: string, day: number): EmploymentTerms | undefined;
@@ -0,0 +1,188 @@
1
+ import { FilterClause } from '../analytics/report';
2
+ import { OwnerScope } from '../scope/types';
3
+ /**
4
+ * Where a planned row stands.
5
+ *
6
+ * `draft` is the whole reason this field exists: a planner builds next week over an afternoon,
7
+ * and half a roster must not read as coverage while they do. `requested`/`declined` only ever
8
+ * occur on an approvable kind (leave) — see {@link ScheduleKind.approvable}.
9
+ */
10
+ export type ScheduleStatus = "draft" | "published" | "requested" | "approved" | "declined" | "cancelled";
11
+ /** Who asked for this row, and why when a system did. */
12
+ export interface ScheduleEntrySource {
13
+ initiator: "user" | "system";
14
+ /** Free reason on `system` ("copy_week", "playbook"). Same shape as WorkItemSource. */
15
+ reason?: string;
16
+ }
17
+ /**
18
+ * One person, one time span, one kind of time.
19
+ *
20
+ * Shift, break, training, leave request, project allocation and customer appointment are all
21
+ * this row with a different `kindKey`. They share the span, the person, the overlap rules, the
22
+ * grid, publication and the capacity arithmetic; what differs is whether the kind *makes*
23
+ * capacity, *spends* it or *takes it away*, and that is two flags on the kind — not three tables.
24
+ */
25
+ export interface ScheduleEntry {
26
+ id: string;
27
+ organizationId: string;
28
+ /** Who. **Absent = an open shift** — planned work with no name on it, visible as a gap. */
29
+ userId?: string;
30
+ /** Only on an open shift: the role being looked for. A free string until skills exist. */
31
+ placeholderRole?: string;
32
+ /** Which kind of time. See `FIXED_KINDS`; an unknown key is treated as unproductive. */
33
+ kindKey: string;
34
+ /** Epoch ms, half-open `[start, end)` — the same convention as analytics' DateRange. */
35
+ start: number;
36
+ end: number;
37
+ status: ScheduleStatus;
38
+ /** Where this time goes on the demand side. Absent on a shift = unallocated capacity. */
39
+ workstreamId?: string;
40
+ /** Where it goes on the work side: `work_project:7`, `work_item:42`, `company:9`. */
41
+ scopeKey?: string;
42
+ /** Which team this time is worked for. Feeds `WorkMode.source = "roster"` later. */
43
+ teamId?: string;
44
+ /**
45
+ * Which `WorkType` the hours on this block will be booked under.
46
+ *
47
+ * The join with `apps/time`: planned and booked time land on the same key, so planned-versus-
48
+ * actual is one grouping instead of a mapping table. Absent = the workstream's default.
49
+ */
50
+ workTypeKey?: string;
51
+ note?: string;
52
+ /** Set once a published row reached the person's calendar. Absent = never pushed. */
53
+ calendarEventId?: string;
54
+ externalIds?: string[];
55
+ source: ScheduleEntrySource;
56
+ createdBy: string;
57
+ createdAt?: number;
58
+ updatedAt?: number;
59
+ }
60
+ /**
61
+ * How a workstream's demand is derived.
62
+ *
63
+ * Three shapes, one output: required person-seconds per interval. `metric` is the support case
64
+ * (history → forecast), `source` the delivery case (an app that knows what work is coming),
65
+ * `fixed` the back-office case (always two people on it).
66
+ *
67
+ * `source` is a provider role and not a list of app names on purpose: planning must not grow a
68
+ * branch per app that can generate work. `metric` needs no provider because analytics already
69
+ * federates every app's volume — a source only declares itself when its demand is not a
70
+ * countable stream with a handle time.
71
+ */
72
+ export type WorkstreamDemand = {
73
+ kind: "metric";
74
+ /** An analytics metric id, e.g. "comms.conversations.created". */
75
+ metricId: string;
76
+ /** The mapping rule. There is no second rule engine — this *is* it. */
77
+ filters?: FilterClause[];
78
+ /** Average handle time per unit, in seconds. */
79
+ handleSeconds: number;
80
+ /** A duration metric to read AHT from instead of the fixed number. Not in v1. */
81
+ handleSecondsMetricId?: string;
82
+ } | {
83
+ kind: "source";
84
+ /** The app answering `POST /provider/demand/query`. See {@link DemandSourceDescribe}. */
85
+ sourceApp: string;
86
+ /** Opaque to planning, validated by the source: `{ projectIds, cycleId }` for apps/work. */
87
+ params?: Record<string, unknown>;
88
+ } | {
89
+ kind: "fixed";
90
+ headcount: number;
91
+ /** Local hours the fixed staffing applies to, e.g. `[8, 18]`. Absent = around the clock. */
92
+ weekdayHours?: [number, number];
93
+ };
94
+ /**
95
+ * How required work turns into required people.
96
+ *
97
+ * `queue` is for work that arrives and waits — you steer on how fast it gets picked up. `hours`
98
+ * is for work that is already known — you divide it by the clock. Named after the question a
99
+ * planner answers, not after the maths: `queue` runs Erlang C underneath (see `erlang.ts`), and
100
+ * a field called `erlang_c` asks every reader to know a 1917 formula before they can pick one.
101
+ *
102
+ * `unplannedLossPercent` — shrinkage, in the trade — defaults to 0 on purpose: most WFM tools
103
+ * assume 25-35% because breaks, training and meetings are not in their roster. Here they are, as
104
+ * rows that give no coverage. It is only for what you *cannot* roster (sick leave, ad hoc), and
105
+ * counting it twice is the classic error.
106
+ */
107
+ export type StaffingPolicy = {
108
+ model: "hours";
109
+ unplannedLossPercent?: number;
110
+ } | {
111
+ model: "queue";
112
+ /** e.g. 80 for "80% of contacts answered within `targetSeconds`". */
113
+ serviceLevelPercent: number;
114
+ targetSeconds: number;
115
+ /** Cap on how busy an agent may be, e.g. 85. Absent = no cap. */
116
+ maxOccupancyPercent?: number;
117
+ unplannedLossPercent?: number;
118
+ };
119
+ /** A bucket demand comes from: an inbox, a project, a process, a pipeline. */
120
+ export interface Workstream {
121
+ id: string;
122
+ organizationId: string;
123
+ /** Org-unique slug — the same shape as `WorkType.key`. */
124
+ key: string;
125
+ name: string;
126
+ description?: string;
127
+ color?: string;
128
+ icon?: string;
129
+ /** Team or org, never personal: a workstream nobody but you can see plans nothing. */
130
+ ownerScope: OwnerScope;
131
+ demand: WorkstreamDemand;
132
+ staffing: StaffingPolicy;
133
+ /**
134
+ * The `WorkType` blocks on this workstream get when nobody picks one.
135
+ *
136
+ * Deliberately not a `billable` flag of its own. Billability already has a home —
137
+ * `WorkType.defaultBillable`, which every booked hour resolves through — and a second flag
138
+ * here would drift from it the first time somebody changes one of the two.
139
+ */
140
+ defaultWorkTypeKey?: string;
141
+ archived?: boolean;
142
+ order?: number;
143
+ createdBy: string;
144
+ createdAt?: number;
145
+ updatedAt?: number;
146
+ }
147
+ /**
148
+ * A human override on a forecast, and the only forecast row that is stored.
149
+ *
150
+ * The forecast itself is a pure function of history + these; recomputing it on read costs less
151
+ * than a job plus the "why is this number stale" question that a stored forecast creates.
152
+ */
153
+ export interface ForecastAdjustment {
154
+ id: string;
155
+ organizationId: string;
156
+ workstreamId: string;
157
+ /** Epoch ms, half-open `[from, to)`. */
158
+ from: number;
159
+ to: number;
160
+ /** A multiplier (1.3 = campaign week). Ignored when `absoluteVolume` is set. */
161
+ factor?: number;
162
+ /** An absolute volume per interval. Wins over `factor`. */
163
+ absoluteVolume?: number;
164
+ note?: string;
165
+ createdBy: string;
166
+ createdAt?: number;
167
+ }
168
+ /**
169
+ * One interval of the coverage answer. Derived on read, never stored.
170
+ *
171
+ * Headcounts are FTE-equivalents (`seconds / intervalSeconds`) and may be fractional: somebody
172
+ * who works half the hour covers half of it. Rounding to whole people here would make a
173
+ * half-staffed interval look either fully covered or empty.
174
+ */
175
+ export interface CoverageInterval {
176
+ /** "2026-09-17T09" — the same period key `MetricFact` uses. */
177
+ period: string;
178
+ forecastVolume?: number;
179
+ requiredSeconds: number;
180
+ requiredHeadcount: number;
181
+ scheduledSeconds: number;
182
+ scheduledHeadcount: number;
183
+ /** `scheduled - required`. Negative = short. */
184
+ netHeadcount: number;
185
+ /** All shift time, whether or not it is allocated to this workstream. */
186
+ capacitySeconds: number;
187
+ absentSeconds: number;
188
+ }
@@ -60,6 +60,18 @@ export declare function levelOrder(levelKey: string, levels?: readonly StrategyL
60
60
  * key this platform does not recognise.
61
61
  */
62
62
  export declare function canParent(childLevelKey: string, parentLevelKey: string, levels?: readonly StrategyLevel[]): boolean;
63
+ /**
64
+ * The rung a child of this row gets by default.
65
+ *
66
+ * What the inline "+" on a row creates: one step down the ladder, so a focus area yields a goal
67
+ * and a goal yields a key result. **The deepest rung yields itself** rather than nothing — a key
68
+ * result under a key result is unusual but legal (`canParent` allows equal rungs), and returning
69
+ * `undefined` would mean the affordance disappears exactly where a team is working deepest.
70
+ *
71
+ * An unknown level ranks last, so it also yields itself. That is the safe direction: a level a
72
+ * source invented gets a sibling, not a guess at what sits under it.
73
+ */
74
+ export declare function nextLevelBelow(levelKey: string, levels?: readonly StrategyLevel[]): string;
63
75
  /**
64
76
  * Which of our own rungs a source's name means, or `null`.
65
77
  *
@@ -4,11 +4,19 @@ import { StrategyHealth, StrategyItem, StrategyMeasure, StrategyProgress } from
4
4
  *
5
5
  * Measured from `startValue`, not from zero: "from 5h30m down to 2h" is 0% on day one, while a
6
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.
7
+ * anything.
8
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.
9
+ * **`direction` and the bounds have to agree, and this is where they are checked.** The maths
10
+ * needs no `direction` at all a falling goal is one whose target lies below its start, and the
11
+ * division handles that by itself. The field exists for the one case the bounds cannot express
12
+ * (holding a number steady, where start equals target) and for the label on the form. So when the
13
+ * two disagree — "lower is better" with a target *above* the start, which a form will happily
14
+ * accept — the bounds are not a scale at all, and this falls back to the only honest answer:
15
+ * "are we there, yes or no". Dividing anyway is what reported a goal as 100% done the moment its
16
+ * value went the wrong way.
17
+ *
18
+ * A value that cannot be a number is 0, never `NaN`: a `NaN` ratio renders as an empty bar with
19
+ * no width and reads as "not started".
12
20
  */
13
21
  export declare function measureRatio(measure: Pick<StrategyMeasure, "direction" | "startValue" | "targetValue">, currentValue: number): number;
14
22
  /**