@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.
@@ -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,69 @@
1
+ import { DateRange } from '../analytics/report';
2
+ import { DemandPointResponse } from './contracts';
3
+ import { WorkPattern } from './pattern';
4
+ import { EmploymentTerms } from './terms';
5
+ import { ScheduleEntry } from './types';
6
+ /**
7
+ * How much capacity somebody has, and how precisely we can say it.
8
+ *
9
+ * Three layers, each with its own grain, each overriding the one below for the period it covers.
10
+ * They do not compete, because they do not measure the same thing:
11
+ *
12
+ * | Layer | Says | Grain |
13
+ * |---|---|---|
14
+ * | roster | this person, this span | interval |
15
+ * | pattern | the shape of this day | day, or interval with a start time |
16
+ * | terms | the amount of this week | week |
17
+ *
18
+ * > **Per person per day the highest layer that says anything wins, whole.**
19
+ * > A rostered day ignores the pattern; a patterned day ignores the terms. Without that rule the
20
+ * > sources add up — the shrinkage mistake in a new coat, for the third time.
21
+ *
22
+ * And the honest consequence: with only terms there is no answer at interval grain. Smearing
23
+ * thirty-two hours over a hundred and sixty-eight intervals is invented data, so `precision` says
24
+ * `"week"` and the screen shows a week instead of drawing an hourly chart.
25
+ */
26
+ /** The weakest link decides: a total is only as precise as its vaguest contributing day. */
27
+ export type CapacityPrecision = "interval" | "day" | "week";
28
+ export interface CapacityTotal {
29
+ seconds: number;
30
+ precision: CapacityPrecision;
31
+ /** Per person, same arithmetic. The key is the user id. */
32
+ byUser: Record<string, number>;
33
+ }
34
+ export interface CapacitySources {
35
+ entries: readonly ScheduleEntry[];
36
+ patterns?: readonly WorkPattern[];
37
+ terms?: readonly EmploymentTerms[];
38
+ /** Who to count. Absent = everybody the entries, patterns and terms mention. */
39
+ userIds?: readonly string[];
40
+ teamIdsOf?: (userId: string) => readonly string[];
41
+ }
42
+ /**
43
+ * The capacity in a window, by the ladder above.
44
+ *
45
+ * Days are walked rather than intervals: the layers switch per day, and a week's worth of terms
46
+ * is shared out over the days no higher layer covered — `weeklyMinutes × uncoveredDays / 7`. That
47
+ * is the only division that needs no assumption about which days are working days.
48
+ */
49
+ export declare function capacityTotal(range: DateRange, sources: CapacitySources): CapacityTotal;
50
+ export interface PersonCoverage {
51
+ /** `null` is the pot nobody has taken yet — what is still to be divided. */
52
+ userId: string | null;
53
+ requiredSeconds: number;
54
+ capacitySeconds: number;
55
+ /** `capacity − required`. Negative = this person is over-committed. */
56
+ netSeconds: number;
57
+ }
58
+ /**
59
+ * Required against capacity, per person.
60
+ *
61
+ * The number that blocks a sprint. "Ten hours short on the team" is rarely the problem; "Daan has
62
+ * twelve hours of work and eight hours of time while Iris has four and eight" is, every time — and
63
+ * a team total hides exactly the imbalance somebody can do something about.
64
+ *
65
+ * Work nobody has taken stays a pot of its own rather than being divided over the team: it is
66
+ * precisely what is still to be assigned, and pretending it is already somebody's would erase the
67
+ * one thing that is actionable.
68
+ */
69
+ export declare function coverageByPerson(points: readonly DemandPointResponse[], capacity: CapacityTotal): PersonCoverage[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
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
+ * Whose work this already is, carried through from the lump it came from.
48
+ *
49
+ * The interval view ignores it and sums; the per-person view groups on it. Without it, placing
50
+ * a lump would throw away the one thing that makes a sprint's imbalance visible.
51
+ */
52
+ userId?: string;
53
+ }
54
+ /**
55
+ * Work the source knows the size of but not the shape of.
56
+ *
57
+ * "748 hours, due by the ninth, 64 of them Bram's" — that is everything a work source can
58
+ * honestly say. *When* those hours land is a question about when people are available, and the
59
+ * source has no idea: it cannot see a roster, a working pattern or a holiday.
60
+ *
61
+ * So it answers in lumps and planning places them. That is also what keeps office hours out of
62
+ * `packages/domain`: an earlier version spread work over Monday-to-Friday 09:00-17:00 because
63
+ * somebody had to decide, and every source inherited the assumption.
64
+ */
65
+ export interface DemandLump {
66
+ seconds: number;
67
+ /** Deadline in epoch ms. Absent = no later than the end of the asked window. */
68
+ by?: number;
69
+ /** Whose work this already is. A forecast never knows; an assigned work item always does. */
70
+ userId?: string;
71
+ }
72
+ export interface DemandQueryResponse {
73
+ /** Bucketed, for a source that knows its own shape (an hourly forecast). */
74
+ points?: DemandPointResponse[];
75
+ /** Unplaced, for a source that knows the size and the deadline. See {@link DemandLump}. */
76
+ lumps?: DemandLump[];
77
+ /** The source's natural period, if it has one — a sprint, a phase. Feeds the range picker. */
78
+ window?: {
79
+ from: number;
80
+ to: number;
81
+ label: string;
82
+ };
83
+ /** Optional note the source wants shown next to the numbers ("14 items zonder schatting"). */
84
+ caveat?: string;
85
+ }
86
+ /**
87
+ * `POST /availability` — who is working right now, and on what.
88
+ *
89
+ * The question routing, nudging and assignment all ask, and the reason planning is worth calling
90
+ * from another app at all: a conversation should not land with somebody who is off shift, and an
91
+ * agent should not be nudged while on leave.
92
+ *
93
+ * ponytail: one instant, not a range. "Is this person free on Thursday afternoon" is a different
94
+ * question — it needs the calendar's free/busy, which does not exist yet — and answering it here
95
+ * would mean inventing a half version of it.
96
+ */
97
+ export interface AvailabilityRequest {
98
+ /** Ask about these people, or about a whole team. One of the two. */
99
+ userIds?: string[];
100
+ teamId?: string;
101
+ /** Epoch ms. Absent = now. */
102
+ at?: number;
103
+ }
104
+ export interface AvailabilitySlot {
105
+ userId: string;
106
+ /** Is there a published shift covering this instant? */
107
+ onShift: boolean;
108
+ /** Is approved leave, sickness or a holiday covering it? Distinct from merely not on shift. */
109
+ absent: boolean;
110
+ /** The workstreams this person's time is attributed to right now. Empty = unallocated. */
111
+ workstreamIds: string[];
112
+ /** When the current shift ends, if on one. Lets a caller avoid handing over work at 16:58. */
113
+ shiftEndsAt?: number;
114
+ }
115
+ export interface AvailabilityResponse {
116
+ at: number;
117
+ slots: AvailabilitySlot[];
118
+ }
@@ -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,11 @@
1
+ export * from './capacity';
2
+ export * from './contracts';
3
+ export * from './coverage';
4
+ export * from './erlang';
5
+ export * from './forecast';
6
+ export * from './keys';
7
+ export * from './kinds';
8
+ export * from './pattern';
9
+ export * from './spread';
10
+ export * from './terms';
11
+ 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,63 @@
1
+ /**
2
+ * A working pattern: capacity that is simply there, without anybody rostering it.
3
+ *
4
+ * A development team works nine to five and never opens a roster; a nurse works four on, four
5
+ * off; somebody on standby carries a week in every three. All three are the same thing — a cycle
6
+ * of days that repeats from a fixed starting point — so this is one shape, not a weekly one plus
7
+ * a rotating one later.
8
+ *
9
+ * **The pattern never becomes rows.** It is the fallback for a day nobody planned. See
10
+ * `capacityOn` for the precedence rule, which exists to stop the two sources adding up.
11
+ */
12
+ export interface WorkPattern {
13
+ id: string;
14
+ organizationId: string;
15
+ /** Whose. Absent with `teamId` set = the team default, inherited by members without their own. */
16
+ userId?: string;
17
+ teamId?: string;
18
+ /**
19
+ * Minutes per day of the repeating cycle, starting at {@link WorkPattern.anchor}.
20
+ *
21
+ * Seven entries is an ordinary week. Eight is four-on-four-off. Twenty-one is a three-shift
22
+ * rotation. The length *is* the cycle, which is why there is no separate "repeats every" field
23
+ * to keep in step with it.
24
+ */
25
+ cycleMinutes: number[];
26
+ /**
27
+ * The day `cycleMinutes[0]` applies to, at local midnight.
28
+ *
29
+ * A rotation needs a fixed point in the calendar or it cannot be placed; a plain week needs one
30
+ * too, and any Monday will do.
31
+ */
32
+ anchor: number;
33
+ /**
34
+ * Local start time per cycle day (`"09:00"`), only to draw the implied block on the grid.
35
+ * Never used by the arithmetic — capacity is an amount, not a position.
36
+ */
37
+ startTimes?: (string | undefined)[];
38
+ /** From when this holds. A changed contract is a new row, never a patch on the old one. */
39
+ effectiveFrom?: number;
40
+ createdBy?: string;
41
+ createdAt?: number;
42
+ updatedAt?: number;
43
+ }
44
+ /**
45
+ * Whole calendar days between two instants.
46
+ *
47
+ * Counted through `Date`, not by dividing milliseconds: two days a year are 23 or 25 hours long,
48
+ * and a rotation that slips a day every March is a rota nobody trusts again.
49
+ */
50
+ export declare function calendarDaysBetween(from: number, to: number): number;
51
+ /** Which day of the cycle a date falls on. Negative offsets wrap the right way round. */
52
+ export declare function cycleDayIndex(pattern: Pick<WorkPattern, "cycleMinutes" | "anchor">, day: number): number;
53
+ /** Minutes this pattern gives on this day. Zero before it takes effect. */
54
+ export declare function minutesOn(pattern: WorkPattern, day: number): number;
55
+ /** The block the grid draws for a pattern day, if the pattern says where it sits. */
56
+ export declare function patternStartTime(pattern: WorkPattern, day: number): string | undefined;
57
+ /**
58
+ * The pattern that applies to somebody: their own, else their team's.
59
+ *
60
+ * The most recent `effectiveFrom` that has already started wins, so a contract change is made by
61
+ * adding a row and the old one stays readable next to the weeks it governed.
62
+ */
63
+ export declare function patternFor(patterns: readonly WorkPattern[], userId: string, teamIds: readonly string[], day: number): WorkPattern | undefined;
@@ -0,0 +1,61 @@
1
+ import { Grain } from '../analytics/fact';
2
+ import { DateRange } from '../analytics/report';
3
+ import { DemandLump, DemandPointResponse } from './contracts';
4
+ import { WorkPattern } from './pattern';
5
+ import { ScheduleEntry } from './types';
6
+ /**
7
+ * Placing work: how a lump of hours gets spread across a window.
8
+ *
9
+ * **Over the hours people are actually there, in proportion to how many.** Not over office
10
+ * hours — an earlier version of this file hardcoded Monday-to-Friday 09:00-17:00 in
11
+ * `packages/domain`, which every work-driven source then inherited, and a twenty-four-hour desk
12
+ * got its backlog smeared over a working day it does not have.
13
+ *
14
+ * It makes demand depend on capacity, and that is the right direction for the question this
15
+ * serves: *does this fit?* A human placing the same work would do exactly this — look at when
16
+ * people are in, and fill those hours. The totals do not move when you add somebody, only the
17
+ * shape does, so "how much is short" stays an honest number.
18
+ */
19
+ export interface IntervalWeight {
20
+ period: string;
21
+ /** Relative, not absolute: only the ratios between intervals matter. */
22
+ weight: number;
23
+ }
24
+ export interface CapacityWeightOptions {
25
+ grain?: Grain;
26
+ /** Working patterns, for the days nobody rostered. See the precedence rule below. */
27
+ patterns?: readonly WorkPattern[];
28
+ /** Who to count. Absent = everybody the entries mention. */
29
+ userIds?: readonly string[];
30
+ /** Team membership, so a team pattern can be inherited. */
31
+ teamIdsOf?: (userId: string) => readonly string[];
32
+ }
33
+ /**
34
+ * Capacity per interval, as weights.
35
+ *
36
+ * > **Per person per day, a rostered row wins the whole day.**
37
+ * > Is there any capacity row for that person that day, the pattern contributes nothing for that
38
+ * > day; is there none, the pattern does. Without that rule capacity comes from two sources that
39
+ * > add up — the shrinkage mistake in a new coat.
40
+ *
41
+ * A pattern day with a start time becomes a real window. Without one its minutes are spread
42
+ * evenly across that day's intervals: a fallback inside one day, not a guess about office hours.
43
+ */
44
+ export declare function capacityWeights(entries: readonly ScheduleEntry[], range: DateRange, options?: CapacityWeightOptions): IntervalWeight[];
45
+ /**
46
+ * Spread seconds across intervals in proportion to their weight.
47
+ *
48
+ * **All the weight at zero means an even spread.** Work that has to be done in a week nobody is
49
+ * available is still work: bunching it on one interval would read as a single catastrophic hour
50
+ * instead of a week that is short throughout, and the second is what is true.
51
+ */
52
+ export declare function spreadOverWeights(totalSeconds: number, weights: readonly IntervalWeight[]): DemandPointResponse[];
53
+ /**
54
+ * Place every lump a source handed over, each one no later than its own deadline.
55
+ *
56
+ * A lump with a deadline is spread only over the intervals before it — work due Friday does not
57
+ * get planned on Saturday, however available everybody is then. A lump whose deadline is already
58
+ * past falls back on the whole window, because refusing to show it would hide the one thing that
59
+ * is certainly late.
60
+ */
61
+ export declare function placeLumps(lumps: readonly DemandLump[], weights: readonly IntervalWeight[], periodToMs: (period: string) => number): DemandPointResponse[];
@@ -0,0 +1 @@
1
+ export {};