@opencxh/domain 1.232.0 → 1.233.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.
@@ -3,6 +3,7 @@ export * from './dimensions';
3
3
  export * from './fact';
4
4
  export * from './metric';
5
5
  export * from './period';
6
+ export * from './picker';
6
7
  export * from './report';
7
8
  export * from './source';
8
9
  export * from './usage';
@@ -0,0 +1,49 @@
1
+ import { DimensionDefinition, DimensionOption } from './dimensions';
2
+ import { MetricDefinition } from './metric';
3
+ /**
4
+ * Turning a metric catalogue into a picker: which axes a thing may be narrowed on, and what
5
+ * their values are called.
6
+ *
7
+ * In `domain` because it is not one screen's logic. A goal in `apps/work` and a workstream in
8
+ * `apps/planning` ask the same question of the same catalogue, and the two rules below are the
9
+ * kind of knowledge that must not be discovered twice — both of them exist because of a silent
10
+ * failure, not a preference.
11
+ *
12
+ * Which axes a thing may be narrowed on, and what their values are called.
13
+ *
14
+ * Two rules decide, and both have a silent failure behind them:
15
+ *
16
+ * 1. **Only what the metric declares.** The report engine drops a fact that misses the dimension
17
+ * it is asked about, so filtering `comms.reply_time.first` on an axis its extractor never
18
+ * emits yields `0` rather than an error — a goal that reads as "we achieved nothing", or a
19
+ * workstream that needs nobody. `apps/context`'s own metrics carry that warning verbatim.
20
+ * 2. **Only what we can name.** A dimension is offerable here when the catalogue carries its
21
+ * value labels (`options`, which is how a source federates them) or when this app happens to
22
+ * hold the entity list — teams and users. `inbox`, `channel` and `provider` are entity-backed
23
+ * and live in another app, so offering them would mean a picker full of raw ids. A missing
24
+ * option beats an unreadable one.
25
+ */
26
+ export interface FilterableDimension {
27
+ id: string;
28
+ label: string;
29
+ options: DimensionOption[];
30
+ }
31
+ /** Entity lists this app already holds, keyed by the dimension they name. */
32
+ export interface NamedEntities {
33
+ team?: DimensionOption[];
34
+ agent?: DimensionOption[];
35
+ }
36
+ export declare function filterableDimensions(metric: MetricDefinition | undefined, dimensions: readonly DimensionDefinition[], named: NamedEntities): FilterableDimension[];
37
+ /** The readable form of one stored clause: "Team · Support". Falls back to the raw value. */
38
+ export declare function describeClause(clause: {
39
+ dimensionId: string;
40
+ value: string;
41
+ }, dimensions: readonly FilterableDimension[]): string;
42
+ /**
43
+ * One metric as a picker row: its category leads, because that is how somebody looks for it
44
+ * ("something about response time") and the kit's `Select` has no groups.
45
+ */
46
+ export declare function metricOption(metric: MetricDefinition): {
47
+ value: string;
48
+ label: string;
49
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { Grain } from '../analytics/fact';
2
+ /** The provider role group, as a constant so a typo does not silently yield an empty list. */
3
+ export declare const DEMAND_SOURCE_PROVIDER_GROUP = "demand-source";
4
+ /**
5
+ * `GET /provider/demand/describe` — what this app can be a demand source for.
6
+ *
7
+ * `configResource` is the app's own settings UI for a workstream pointed at it, keyed
8
+ * `"<app>:<Component>"` like every other federated resource. That is deliberately not a
9
+ * parameter schema: this platform renders a source's configuration by loading the source's own
10
+ * component, so a new demand source needs zero changes in the workstream form.
11
+ */
12
+ export interface DemandSourceDescribe {
13
+ /** The declaring app (== manifest.name == req.source.app). */
14
+ source: string;
15
+ /** Human-readable, in the source language; the UI may localise. */
16
+ label: string;
17
+ icon?: string;
18
+ configResource?: string;
19
+ }
20
+ /**
21
+ * `POST /provider/demand/query` — how much work is coming, bucketed.
22
+ *
23
+ * `params` is opaque to planning and validated by the source: the same arrangement a sync
24
+ * connector's parameters have. Planning stores it on the workstream and hands it back unread.
25
+ */
26
+ export interface DemandQueryRequest {
27
+ workstreamId: string;
28
+ /** Epoch ms, half-open `[from, to)`. */
29
+ from: number;
30
+ to: number;
31
+ grain: Grain;
32
+ params?: Record<string, unknown>;
33
+ }
34
+ /**
35
+ * One bucket of demand.
36
+ *
37
+ * A source answers in **seconds of work**, because that is the one unit every staffing model
38
+ * takes. `volume` rides along only when the source counts things a person would recognise
39
+ * (conversations, orders) — it is shown, never recomputed.
40
+ */
41
+ export interface DemandPointResponse {
42
+ /** Period key matching the requested grain: "2026-09-17T09" or "2026-09-17". */
43
+ period: string;
44
+ seconds: number;
45
+ volume?: number;
46
+ }
47
+ export interface DemandQueryResponse {
48
+ points: DemandPointResponse[];
49
+ /** Optional note the source wants shown next to the numbers ("14 items zonder schatting"). */
50
+ caveat?: string;
51
+ }
52
+ /**
53
+ * `POST /availability` — who is working right now, and on what.
54
+ *
55
+ * The question routing, nudging and assignment all ask, and the reason planning is worth calling
56
+ * from another app at all: a conversation should not land with somebody who is off shift, and an
57
+ * agent should not be nudged while on leave.
58
+ *
59
+ * ponytail: one instant, not a range. "Is this person free on Thursday afternoon" is a different
60
+ * question — it needs the calendar's free/busy, which does not exist yet — and answering it here
61
+ * would mean inventing a half version of it.
62
+ */
63
+ export interface AvailabilityRequest {
64
+ /** Ask about these people, or about a whole team. One of the two. */
65
+ userIds?: string[];
66
+ teamId?: string;
67
+ /** Epoch ms. Absent = now. */
68
+ at?: number;
69
+ }
70
+ export interface AvailabilitySlot {
71
+ userId: string;
72
+ /** Is there a published shift covering this instant? */
73
+ onShift: boolean;
74
+ /** Is approved leave, sickness or a holiday covering it? Distinct from merely not on shift. */
75
+ absent: boolean;
76
+ /** The workstreams this person's time is attributed to right now. Empty = unallocated. */
77
+ workstreamIds: string[];
78
+ /** When the current shift ends, if on one. Lets a caller avoid handing over work at 16:58. */
79
+ shiftEndsAt?: number;
80
+ }
81
+ export interface AvailabilityResponse {
82
+ at: number;
83
+ slots: AvailabilitySlot[];
84
+ }
85
+ /**
86
+ * Spread a lump of work evenly over the working days of a range.
87
+ *
88
+ * What every work-driven demand source needs and none of them should write twice: a project with
89
+ * 240 open hours and a deadline in three weeks is not 240 hours on the deadline, it is what you
90
+ * have to place per day to get there.
91
+ *
92
+ * **Weekends get nothing.** Not a setting: a source that genuinely runs seven days a week says
93
+ * so by asking for a range of seven-day weeks, and a five-day office that got its estimate
94
+ * smeared over Saturday would read as short every Monday.
95
+ *
96
+ * ponytail: even spread, no calendar of public holidays and no per-person capacity. Both are
97
+ * knowable — holidays from the roster, capacity from the shifts — the day the difference between
98
+ * "what must be done" and "who can do it" is worth two numbers instead of one.
99
+ */
100
+ export declare function spreadSeconds(totalSeconds: number, from: number, to: number, grain?: Grain): DemandPointResponse[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { Grain } from '../analytics/fact';
2
+ import { DateRange } from '../analytics/report';
3
+ import { WorkType } from '../time-entry/types';
4
+ import { ScheduleKind } from './kinds';
5
+ import { CoverageInterval, ScheduleEntry, StaffingPolicy, Workstream } from './types';
6
+ /** Do two half-open spans `[start, end)` touch? Ends that meet do not overlap. */
7
+ export declare function overlaps(a: {
8
+ start: number;
9
+ end: number;
10
+ }, b: {
11
+ start: number;
12
+ end: number;
13
+ }): boolean;
14
+ /** How many seconds of `entry` fall inside `[from, to)`. Zero when they only touch. */
15
+ export declare function overlapSeconds(entry: {
16
+ start: number;
17
+ end: number;
18
+ }, from: number, to: number): number;
19
+ /** What one interval asks for, before staffing turns it into people. */
20
+ export interface DemandPoint {
21
+ /** Contacts/items arriving in this interval (metric-driven). */
22
+ volume?: number;
23
+ /** Work seconds needed in this interval (work-driven or fixed). */
24
+ seconds?: number;
25
+ /** Average handle time; required alongside `volume`. */
26
+ handleSeconds?: number;
27
+ }
28
+ /**
29
+ * Turn one interval's demand into required work and required people.
30
+ *
31
+ * `queue` needs a volume and a handle time; asked without them (a work- or fixed-driven
32
+ * workstream) it falls back to flat hours rather than refusing — the number would otherwise be
33
+ * missing on exactly the screens that mix both kinds of workstream.
34
+ */
35
+ export declare function requirementFor(point: DemandPoint, staffing: StaffingPolicy, intervalSeconds: number): {
36
+ seconds: number;
37
+ headcount: number;
38
+ };
39
+ export interface CoverageRequest {
40
+ entries: readonly ScheduleEntry[];
41
+ workstreamId: string;
42
+ staffing: StaffingPolicy;
43
+ /** Demand per period key. A missing key is an interval nothing is expected in. */
44
+ demand?: Record<string, DemandPoint>;
45
+ range: DateRange;
46
+ grain?: Grain;
47
+ kinds?: readonly ScheduleKind[];
48
+ }
49
+ /**
50
+ * Required versus scheduled, interval by interval — the whole feature in one function.
51
+ *
52
+ * Walks the range once per interval and the entries once per interval; at an hour grain over
53
+ * eight weeks that is ~1300 buckets, which is why the range is capped at the route rather than
54
+ * here. Everything it reads is already in memory, so there is no I/O to batch.
55
+ */
56
+ export declare function coverageFor(request: CoverageRequest): CoverageInterval[];
57
+ /**
58
+ * The work type a planned block books under, if anything says so.
59
+ *
60
+ * The entry beats the workstream default — the same precedence `TimeEntry.billable` has over
61
+ * `WorkType.defaultBillable`: the concrete row wins from the setting it inherited.
62
+ */
63
+ export declare function plannedWorkTypeKey(entry: Pick<ScheduleEntry, "workTypeKey">, workstream?: Pick<Workstream, "defaultWorkTypeKey">): string | undefined;
64
+ /**
65
+ * Does this planned block bill to somebody?
66
+ *
67
+ * Resolved through `WorkType.defaultBillable` and nowhere else, so planned and booked hours
68
+ * answer it from the same row. A block with no work type is not billable: unattributed time
69
+ * that silently counts as revenue is the error worth being wrong about in the other direction.
70
+ */
71
+ export declare function plannedBillable(entry: Pick<ScheduleEntry, "workTypeKey">, workstream: Pick<Workstream, "defaultWorkTypeKey"> | undefined, workTypes: readonly Pick<WorkType, "key" | "defaultBillable">[]): boolean;
72
+ /** The intervals that are short-staffed — what a nudge and the shortage badge both read. */
73
+ export declare function shortfalls(intervals: readonly CoverageInterval[]): CoverageInterval[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Erlang C: how many people a queue needs to hit a service level.
3
+ *
4
+ * The 1917 formula every WFM product still runs on. Kept here as ~40 lines of arithmetic rather
5
+ * than a dependency, and computed through the Erlang B recursion because the textbook form
6
+ * (`A^N / N!`) overflows a double somewhere around N = 170.
7
+ */
8
+ /**
9
+ * The probability that an arriving contact has to wait.
10
+ *
11
+ * Returns 1 when the queue is offered at least as much traffic as it has agents: without a free
12
+ * agent in the long run, everybody waits.
13
+ */
14
+ export declare function erlangC(agents: number, traffic: number): number;
15
+ /**
16
+ * The fraction of contacts answered within `targetSeconds`.
17
+ *
18
+ * `SL = 1 - C · e^(-(N - A)·target / AHT)` — the standard waiting-time tail of the M/M/N queue.
19
+ */
20
+ export declare function serviceLevel(agents: number, traffic: number, targetSeconds: number, handleSeconds: number): number;
21
+ /** Traffic intensity in erlangs: the share of the interval all the work adds up to. */
22
+ export declare function trafficIntensity(volume: number, handleSeconds: number, intervalSeconds: number): number;
23
+ export interface QueuePolicy {
24
+ /** e.g. 80 for "80% answered within `targetSeconds`". */
25
+ serviceLevelPercent: number;
26
+ targetSeconds: number;
27
+ /** Cap on `traffic / agents`, e.g. 85. Absent = no cap. */
28
+ maxOccupancyPercent?: number;
29
+ }
30
+ /**
31
+ * The smallest number of agents that meets the service level and respects the occupancy cap.
32
+ *
33
+ * Walks up from `ceil(A)` rather than solving, because the answer is a small integer and a loop
34
+ * is easier to read than an inversion. An occupancy cap can push the answer well past what the
35
+ * service level alone needs — that is its purpose: agents who are busy 98% of an hour burn out.
36
+ */
37
+ export declare function requiredAgents(volume: number, handleSeconds: number, intervalSeconds: number, policy: QueuePolicy): number;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { DateRange } from '../analytics/report';
2
+ import { ForecastAdjustment } from './types';
3
+ /** One historical bucket, exactly as the analytics report engine returns it. */
4
+ export interface HistoryPoint {
5
+ /** "YYYY-MM-DDTHH", hour grain, UTC. */
6
+ period: string;
7
+ value: number;
8
+ }
9
+ export interface ForecastPoint {
10
+ period: string;
11
+ volume: number;
12
+ }
13
+ export interface ForecastOptions {
14
+ /** How many weeks back to average. More weeks is steadier and slower to react. */
15
+ weeks?: number;
16
+ adjustments?: readonly ForecastAdjustment[];
17
+ }
18
+ export declare const DEFAULT_FORECAST_WEEKS = 8;
19
+ /**
20
+ * Forecast per hour from the same hour in recent weeks, with a trend and human overrides.
21
+ *
22
+ * Seasonal-naive on purpose. A support desk's week repeats, so "this Thursday at 10:00 looks
23
+ * like the last eight Thursdays at 10:00" is most of the signal, and a team lead can read the
24
+ * number back off the history. Anything cleverer has to be believed rather than checked.
25
+ *
26
+ * **A missing bucket is a zero, not a gap — but only on a day the source reported at all.**
27
+ * Analytics stores no fact for an hour with no traffic, so treating every absence as unknown
28
+ * would forecast the night away; treating a day the connector was down as zeros would forecast a
29
+ * quiet week that never happened.
30
+ *
31
+ * ponytail: seasonal-naive + linear trend. Swap in a real model only once somebody can show a
32
+ * week where this was materially wrong — the signature stays the same.
33
+ */
34
+ export declare function forecastIntervals(history: readonly HistoryPoint[], target: DateRange, options?: ForecastOptions): ForecastPoint[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export * from './contracts';
2
+ export * from './coverage';
3
+ export * from './erlang';
4
+ export * from './forecast';
5
+ export * from './keys';
6
+ export * from './kinds';
7
+ export * from './types';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The scope kinds this layer claims.
3
+ *
4
+ * In `domain` and not in the app because they are a contract: the client builds a key to open a
5
+ * picker, the planning server authorises it, and an hour booked on an allocation carries it.
6
+ * Two places spelling a prefix separately drift apart on the first typo, and the symptom is
7
+ * "no access to this scope" rather than an error that says what is wrong.
8
+ */
9
+ export declare const SCHEDULE_ENTRY_SCOPE_KIND = "schedule_entry";
10
+ export declare const WORKSTREAM_SCOPE_KIND = "workstream";
11
+ export declare function scheduleEntryScopeKey(entryId: string): string;
12
+ export declare function workstreamScopeKey(workstreamId: string): string;
@@ -0,0 +1,56 @@
1
+ import { ScheduleEntry, ScheduleStatus } from './types';
2
+ /**
3
+ * One kind of scheduled time, and the three questions every consumer asks of it.
4
+ *
5
+ * Same shape as `FIXED_LEVELS` in strategy: a fixed set that is ours, plus a resolution that
6
+ * decides what an unknown key means. The flags are separate because they are independent — a
7
+ * shift makes capacity without covering anything, an allocation covers without making capacity.
8
+ */
9
+ export interface ScheduleKind {
10
+ key: string;
11
+ /** An i18n key, not a literal: a string here would fix the language at module load. */
12
+ label: string;
13
+ /** Does this kind make capacity? Only a shift does. */
14
+ capacity: boolean;
15
+ /** Does this kind cover its workstream? */
16
+ productive: boolean;
17
+ /** Does this kind take capacity away (leave, sick, holiday)? */
18
+ absent: boolean;
19
+ /** Does this kind need approving before it counts? */
20
+ approvable?: boolean;
21
+ /** Lucide icon name. */
22
+ icon?: string;
23
+ }
24
+ /**
25
+ * The kinds that are **ours** — the legend of the schedule grid.
26
+ *
27
+ * Taken from Intercom's activity legend and Float's allocation model, which agree on the same
28
+ * eight: they are the categories a person's working day actually splits into.
29
+ */
30
+ export declare const FIXED_KINDS: readonly ScheduleKind[];
31
+ /**
32
+ * What an unrecognised kind key means: time that blocks the person and covers nothing.
33
+ *
34
+ * The safe direction. A key some import invented must never silently *add* capacity or coverage;
35
+ * being merely busy is the reading that cannot inflate a staffing number.
36
+ */
37
+ export declare const UNKNOWN_KIND: ScheduleKind;
38
+ export declare function kindFor(kindKey: string, kinds?: readonly ScheduleKind[]): ScheduleKind;
39
+ /**
40
+ * Does this row count as planned reality?
41
+ *
42
+ * The one status gate, in one place. A draft covers nothing, a leave request takes nothing away
43
+ * until somebody approves it, and a declined or cancelled row is history. Everything that reads
44
+ * a schedule — coverage, capacity, the calendar push, the analytics rollup — asks exactly this.
45
+ */
46
+ export declare function countsAsPlanned(status: ScheduleStatus): boolean;
47
+ /**
48
+ * Does this row cover `workstreamId`?
49
+ *
50
+ * A shift that names a workstream covers it directly — the support case, where the whole shift
51
+ * *is* inbox time and a second row per day would be bookkeeping for its own sake. An allocation
52
+ * covers by being productive. Absent kinds never cover, whatever they name.
53
+ */
54
+ export declare function coversWorkstream(entry: Pick<ScheduleEntry, "kindKey" | "workstreamId">, workstreamId: string, kinds?: readonly ScheduleKind[]): boolean;
55
+ /** Does this row belong on the person's calendar once published? Breaks and drafts do not. */
56
+ export declare function belongsOnCalendar(entry: Pick<ScheduleEntry, "kindKey" | "status" | "userId">, kinds?: readonly ScheduleKind[]): boolean;
@@ -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
  /**