@opencxh/domain 1.233.0 → 1.234.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.
- package/dist/entities/planning/capacity.d.ts +69 -0
- package/dist/entities/planning/contracts.d.ts +35 -17
- package/dist/entities/planning/index.d.ts +4 -0
- package/dist/entities/planning/pattern.d.ts +67 -0
- package/dist/entities/planning/spread.d.ts +61 -0
- package/dist/entities/planning/spread.test.d.ts +1 -0
- package/dist/entities/planning/terms.d.ts +42 -0
- package/dist/index.cjs +16 -16
- package/dist/index.js +1691 -1521
- package/package.json +1 -1
- /package/dist/entities/planning/{contracts.test.d.ts → capacity.test.d.ts} +0 -0
|
@@ -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[];
|
|
@@ -43,9 +43,43 @@ export interface DemandPointResponse {
|
|
|
43
43
|
period: string;
|
|
44
44
|
seconds: number;
|
|
45
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;
|
|
46
71
|
}
|
|
47
72
|
export interface DemandQueryResponse {
|
|
48
|
-
|
|
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
|
+
};
|
|
49
83
|
/** Optional note the source wants shown next to the numbers ("14 items zonder schatting"). */
|
|
50
84
|
caveat?: string;
|
|
51
85
|
}
|
|
@@ -82,19 +116,3 @@ export interface AvailabilityResponse {
|
|
|
82
116
|
at: number;
|
|
83
117
|
slots: AvailabilitySlot[];
|
|
84
118
|
}
|
|
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[];
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
export * from './capacity';
|
|
1
2
|
export * from './contracts';
|
|
2
3
|
export * from './coverage';
|
|
3
4
|
export * from './erlang';
|
|
4
5
|
export * from './forecast';
|
|
5
6
|
export * from './keys';
|
|
6
7
|
export * from './kinds';
|
|
8
|
+
export * from './pattern';
|
|
9
|
+
export * from './spread';
|
|
10
|
+
export * from './terms';
|
|
7
11
|
export * from './types';
|
|
@@ -0,0 +1,67 @@
|
|
|
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 days between two instants, rounded.
|
|
46
|
+
*
|
|
47
|
+
* **Deliberately timezone-free.** An earlier version normalised both sides to local noon, which
|
|
48
|
+
* reads the *evaluating* machine's clock — and the two timestamps come from the browser. On a
|
|
49
|
+
* UTC server a CEST Monday midnight floors to the previous Sunday, the week walks eight days
|
|
50
|
+
* instead of seven, and the eighth wraps onto cycle day zero: five eight-hour days silently
|
|
51
|
+
* became six. Rounding the raw difference needs no shared timezone and absorbs the 23- and
|
|
52
|
+
* 25-hour days of a daylight-saving change on its own.
|
|
53
|
+
*/
|
|
54
|
+
export declare function calendarDaysBetween(from: number, to: number): number;
|
|
55
|
+
/** Which day of the cycle a date falls on. Negative offsets wrap the right way round. */
|
|
56
|
+
export declare function cycleDayIndex(pattern: Pick<WorkPattern, "cycleMinutes" | "anchor">, day: number): number;
|
|
57
|
+
/** Minutes this pattern gives on this day. Zero before it takes effect. */
|
|
58
|
+
export declare function minutesOn(pattern: WorkPattern, day: number): number;
|
|
59
|
+
/** The block the grid draws for a pattern day, if the pattern says where it sits. */
|
|
60
|
+
export declare function patternStartTime(pattern: WorkPattern, day: number): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The pattern that applies to somebody: their own, else their team's.
|
|
63
|
+
*
|
|
64
|
+
* The most recent `effectiveFrom` that has already started wins, so a contract change is made by
|
|
65
|
+
* adding a row and the old one stays readable next to the weeks it governed.
|
|
66
|
+
*/
|
|
67
|
+
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 {};
|
|
@@ -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;
|