@jarenjs/core 0.46.5 → 0.49.2
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/ARCHITECTURE.md +104 -0
- package/README.md +46 -2
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/docs/DATES.md +77 -1
- package/docs/SERIES.md +342 -0
- package/package.json +9 -1
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export type Sample = import('./normalize.js').Sample;
|
|
2
|
+
export type Clock = import('./zone.js').Clock;
|
|
3
|
+
export type CompiledBuckets = {
|
|
4
|
+
/**
|
|
5
|
+
* - the width as it was written
|
|
6
|
+
*/
|
|
7
|
+
every: number | string;
|
|
8
|
+
/**
|
|
9
|
+
* - whether boundaries need the calendar
|
|
10
|
+
*/
|
|
11
|
+
calendar: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* - `'millisecond'`, `'day'` or `'month'`
|
|
14
|
+
*/
|
|
15
|
+
unit: string;
|
|
16
|
+
/**
|
|
17
|
+
* - the count of that unit
|
|
18
|
+
*/
|
|
19
|
+
amount: number;
|
|
20
|
+
/**
|
|
21
|
+
* - fixed width in milliseconds, or 0 when
|
|
22
|
+
* the ladder is a calendar one
|
|
23
|
+
*/
|
|
24
|
+
width: number;
|
|
25
|
+
/**
|
|
26
|
+
* - the clock the boundaries fall on
|
|
27
|
+
*/
|
|
28
|
+
zone: string;
|
|
29
|
+
/**
|
|
30
|
+
* - the anchor, in epoch milliseconds
|
|
31
|
+
*/
|
|
32
|
+
origin: number;
|
|
33
|
+
/**
|
|
34
|
+
* - the boundary at a
|
|
35
|
+
* ladder position; position 0 is `origin`
|
|
36
|
+
*/
|
|
37
|
+
startOf: (index: number) => number;
|
|
38
|
+
/**
|
|
39
|
+
* - the ladder position
|
|
40
|
+
* holding an instant
|
|
41
|
+
*/
|
|
42
|
+
indexOf: (at: number) => number;
|
|
43
|
+
/**
|
|
44
|
+
* - the boundary of the bucket
|
|
45
|
+
* holding an instant
|
|
46
|
+
*/
|
|
47
|
+
floor: (at: number) => number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A duration taken apart on a clock: which family it belongs to, how
|
|
51
|
+
* much of that family's unit it is, and the clock itself.
|
|
52
|
+
*
|
|
53
|
+
* The seam between the two kernels that need the same answer. A bucket
|
|
54
|
+
* ladder and a rolling window both have to know whether `P1M` is
|
|
55
|
+
* arithmetic or a calendar question, and whether `P1D` is 86,400,000
|
|
56
|
+
* milliseconds (it is, on UTC and on a fixed offset) or a day that might
|
|
57
|
+
* be 23 hours long (it is, on a named zone).
|
|
58
|
+
*
|
|
59
|
+
* @param {number | string} every
|
|
60
|
+
* @param {Object} [options] - the clock, as {@link resolveClock} takes it
|
|
61
|
+
* @param {string} [options.zone]
|
|
62
|
+
* @param {number} [options.offset]
|
|
63
|
+
* @param {import('./zone.js').ZoneProvider} [options.provider]
|
|
64
|
+
* @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
|
|
65
|
+
* @returns {{ calendar: boolean, unit: string, amount: number, width: number, clock: Clock }}
|
|
66
|
+
* @throws {TypeError} for a span that is not one positive whole family,
|
|
67
|
+
* or a named zone with no provider
|
|
68
|
+
*/
|
|
69
|
+
export declare function compileSpan(every: number | string, options?: {
|
|
70
|
+
zone?: string;
|
|
71
|
+
offset?: number;
|
|
72
|
+
provider?: import('./zone.js').ZoneProvider;
|
|
73
|
+
disambiguation?: 'reject' | 'earlier' | 'later';
|
|
74
|
+
}): {
|
|
75
|
+
calendar: boolean;
|
|
76
|
+
unit: string;
|
|
77
|
+
amount: number;
|
|
78
|
+
width: number;
|
|
79
|
+
clock: Clock;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The bucket ladder for a specification, validated once and returned as
|
|
83
|
+
* the four boundary operations everything downstream needs.
|
|
84
|
+
*
|
|
85
|
+
* `spec` may be the width alone (`compileBuckets('PT15M')`) or a record
|
|
86
|
+
* with `every` and an optional `origin`. `options` carries the clock —
|
|
87
|
+
* nothing, `{ offset }`, or `{ zone, provider }` — and defaults to UTC.
|
|
88
|
+
*
|
|
89
|
+
* The default `origin` is local `1970-01-01T00:00:00` on that clock, so
|
|
90
|
+
* a daily bucket in `+02:00` falls on local midnight rather than on
|
|
91
|
+
* UTC's, and a monthly bucket falls on the first of the month.
|
|
92
|
+
*
|
|
93
|
+
* @param {number | string | { every: number | string, origin?: number | string }} spec
|
|
94
|
+
* @param {Object} [options] - the clock, as {@link resolveClock} takes it
|
|
95
|
+
* @param {string} [options.zone]
|
|
96
|
+
* @param {number} [options.offset]
|
|
97
|
+
* @param {import('./zone.js').ZoneProvider} [options.provider]
|
|
98
|
+
* @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
|
|
99
|
+
* @returns {CompiledBuckets}
|
|
100
|
+
* @throws {TypeError} for a width that is not a positive whole span, a
|
|
101
|
+
* width mixing calendar and fixed units, an origin naming no instant,
|
|
102
|
+
* or a named zone with no provider
|
|
103
|
+
* @example
|
|
104
|
+
* const b = compileBuckets('PT15M');
|
|
105
|
+
* b.floor(Date.UTC(2026, 0, 1, 9, 7)); // 09:00
|
|
106
|
+
* b.startOf(b.indexOf(0) + 1); // the boundary after the epoch
|
|
107
|
+
*/
|
|
108
|
+
export declare function compileBuckets(spec: number | string | {
|
|
109
|
+
every: number | string;
|
|
110
|
+
origin?: number | string;
|
|
111
|
+
}, options?: {
|
|
112
|
+
zone?: string;
|
|
113
|
+
offset?: number;
|
|
114
|
+
provider?: import('./zone.js').ZoneProvider;
|
|
115
|
+
disambiguation?: 'reject' | 'earlier' | 'later';
|
|
116
|
+
}): CompiledBuckets;
|
|
117
|
+
/**
|
|
118
|
+
* `resampleSeries`' closed specification: the D5 bucket contract, the
|
|
119
|
+
* clock it reads and where a row keeps its instant and its reading.
|
|
120
|
+
*/
|
|
121
|
+
export declare const RESAMPLE_MEMBERS: readonly string[];
|
|
122
|
+
/**
|
|
123
|
+
* Bucket a series, reduce each bucket to one number, and say what the
|
|
124
|
+
* empty ones mean.
|
|
125
|
+
*
|
|
126
|
+
* The result is ascending `{ at, value, count }` records labelled at
|
|
127
|
+
* their bucket's **start**, which is the only label that is a boundary
|
|
128
|
+
* rather than a summary of where the rows happened to land.
|
|
129
|
+
*
|
|
130
|
+
* `count` is the number of **source rows** the bucket held — duplicates
|
|
131
|
+
* and measured gaps included — so it is the honest denominator of what
|
|
132
|
+
* was seen, not of what could be added up. The six value aggregates
|
|
133
|
+
* (`sum`, `mean`, `min`, `max`, `first`, `last`) all skip `null`
|
|
134
|
+
* readings, so `value` is `null` exactly when the bucket had nothing to
|
|
135
|
+
* measure, and `count` tells you whether that was because nobody
|
|
136
|
+
* reported or because everybody reported a gap. `aggregate: 'count'`
|
|
137
|
+
* returns that same row count as the value.
|
|
138
|
+
*
|
|
139
|
+
* The window, when `start`/`end` are not given, is the data's own: the
|
|
140
|
+
* bucket holding the first sample through the bucket holding the last.
|
|
141
|
+
* Pass them when an empty edge matters — an empty Monday is only a
|
|
142
|
+
* missing Monday once the caller says the week starts then.
|
|
143
|
+
*
|
|
144
|
+
* The five fill policies decide what an **empty** bucket says, and
|
|
145
|
+
* nothing else — a bucket that held rows and no numbers reports `null`
|
|
146
|
+
* because that is a measurement:
|
|
147
|
+
*
|
|
148
|
+
* | fill | an empty bucket |
|
|
149
|
+
* |---|---|
|
|
150
|
+
* | `omit` | is not emitted (the default: a gap is not a row) |
|
|
151
|
+
* | `null` | is emitted as `null` |
|
|
152
|
+
* | `zero` | is emitted as `0` |
|
|
153
|
+
* | `locf` | repeats the last value before it |
|
|
154
|
+
* | `linear` | is interpolated between its two neighbours |
|
|
155
|
+
*
|
|
156
|
+
* Neither `locf` nor `linear` invents a value at the leading edge, and
|
|
157
|
+
* `linear` needs a value on **both** sides: with no anchor to carry or
|
|
158
|
+
* to interpolate from, the bucket stays `null`. To seed one, widen the
|
|
159
|
+
* window until the earlier reading falls inside it — the seed is then a
|
|
160
|
+
* bucket with data, which is the only kind of anchor this function will
|
|
161
|
+
* extrapolate from. (With `aggregate: 'count'` an empty bucket is `0` by
|
|
162
|
+
* definition, so fill only decides whether it appears at all.)
|
|
163
|
+
*
|
|
164
|
+
* @param {any[]} rows - the samples, in any order
|
|
165
|
+
* @param {Object} spec
|
|
166
|
+
* @param {number | string} spec.every - the bucket width
|
|
167
|
+
* @param {number | string} [spec.origin] - where a boundary falls
|
|
168
|
+
* (default: local `1970-01-01T00:00:00` on the clock)
|
|
169
|
+
* @param {number | string} [spec.start] - the half-open window's start
|
|
170
|
+
* @param {number | string} [spec.end] - the half-open window's end
|
|
171
|
+
* @param {'sum'|'mean'|'min'|'max'|'first'|'last'|'count'} [spec.aggregate]
|
|
172
|
+
* default `'mean'`
|
|
173
|
+
* @param {'omit'|'null'|'zero'|'locf'|'linear'} [spec.fill] default `'omit'`
|
|
174
|
+
* @param {string} [spec.zone] - a named zone, needing `provider`
|
|
175
|
+
* @param {number} [spec.offset] - minutes east of UTC
|
|
176
|
+
* @param {import('./zone.js').ZoneProvider} [spec.provider]
|
|
177
|
+
* @param {'reject'|'earlier'|'later'} [spec.disambiguation]
|
|
178
|
+
* @param {string | ((item: any, index: number) => any)} [spec.at] - where
|
|
179
|
+
* the instant lives in a source row (default `'at'`)
|
|
180
|
+
* @param {string | ((item: any, index: number) => any)} [spec.value]
|
|
181
|
+
* where the reading lives (default `'value'`)
|
|
182
|
+
* @returns {{ at: number, value: number | null, count: number }[]}
|
|
183
|
+
* @throws {TypeError} for a bad width, origin, window, aggregate or fill,
|
|
184
|
+
* or a row that is not a canonical sample
|
|
185
|
+
* @example
|
|
186
|
+
* resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
|
|
187
|
+
* resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
|
|
188
|
+
*/
|
|
189
|
+
export declare function resampleSeries(rows: any[], spec: {
|
|
190
|
+
every: number | string;
|
|
191
|
+
origin?: number | string;
|
|
192
|
+
start?: number | string;
|
|
193
|
+
end?: number | string;
|
|
194
|
+
aggregate?: 'sum' | 'mean' | 'min' | 'max' | 'first' | 'last' | 'count';
|
|
195
|
+
fill?: 'omit' | 'null' | 'zero' | 'locf' | 'linear';
|
|
196
|
+
zone?: string;
|
|
197
|
+
offset?: number;
|
|
198
|
+
provider?: import('./zone.js').ZoneProvider;
|
|
199
|
+
disambiguation?: 'reject' | 'earlier' | 'later';
|
|
200
|
+
at?: string | ((item: any, index: number) => any);
|
|
201
|
+
value?: string | ((item: any, index: number) => any);
|
|
202
|
+
}): {
|
|
203
|
+
at: number;
|
|
204
|
+
value: number | null;
|
|
205
|
+
count: number;
|
|
206
|
+
}[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type Sample = import('./normalize.js').Sample;
|
|
2
|
+
export type Downsampled = {
|
|
3
|
+
/**
|
|
4
|
+
* - the kept
|
|
5
|
+
* samples, ascending, with every gap still a gap
|
|
6
|
+
*/
|
|
7
|
+
points: (Sample & Record<string, any>)[];
|
|
8
|
+
/**
|
|
9
|
+
* - how many samples went in
|
|
10
|
+
*/
|
|
11
|
+
sourceCount: number;
|
|
12
|
+
/**
|
|
13
|
+
* - how many came out; never more than
|
|
14
|
+
* `target`, and less when a bucket's two extremes were one point
|
|
15
|
+
*/
|
|
16
|
+
renderedCount: number;
|
|
17
|
+
/**
|
|
18
|
+
* - which strategy chose them
|
|
19
|
+
*/
|
|
20
|
+
method: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `downsampleSeries`' closed specification: how many points may come
|
|
24
|
+
* back, which strategy chooses them, and where a row keeps its
|
|
25
|
+
* instant and its reading.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DOWNSAMPLE_MEMBERS: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Reduce a series to at most `target` points without bridging a gap or
|
|
30
|
+
* moving an end.
|
|
31
|
+
*
|
|
32
|
+
* @param {any[]} rows - the samples, in any order
|
|
33
|
+
* @param {Object} spec
|
|
34
|
+
* @param {number} spec.target - the most points to return, at least the
|
|
35
|
+
* mandatory endpoints and gap markers
|
|
36
|
+
* @param {'lttb'|'minmax'} [spec.method] default `'lttb'`
|
|
37
|
+
* @param {string | ((item: any, index: number) => any)} [spec.at]
|
|
38
|
+
* @param {string | ((item: any, index: number) => any)} [spec.value]
|
|
39
|
+
* @returns {Downsampled}
|
|
40
|
+
* @throws {TypeError} for an unknown method, a target that is not a
|
|
41
|
+
* positive whole number, or a row that is not a canonical sample
|
|
42
|
+
* @throws {RangeError} when `target` cannot hold the segment endpoints
|
|
43
|
+
* and gap markers the data requires
|
|
44
|
+
* @example
|
|
45
|
+
* const { points, sourceCount, renderedCount } =
|
|
46
|
+
* downsampleSeries(readings, { target: 800 });
|
|
47
|
+
*/
|
|
48
|
+
export declare function downsampleSeries(rows: any[], spec: {
|
|
49
|
+
target: number;
|
|
50
|
+
method?: 'lttb' | 'minmax';
|
|
51
|
+
at?: string | ((item: any, index: number) => any);
|
|
52
|
+
value?: string | ((item: any, index: number) => any);
|
|
53
|
+
}): Downsampled;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { toEpoch, normalizeSeries, canonicalSeries, normalizeIntervals, lowerBoundTime, upperBoundTime, } from './normalize.js';
|
|
2
|
+
export { containsInstant, overlapsInterval, intersectInterval, mergeIntervals, subtractIntervals, gapsWithin, coverageOf, findSlots, MERGE_MEMBERS, SLOTS_MEMBERS, } from './interval.js';
|
|
3
|
+
export { createIntervalIndex } from './interval-index.js';
|
|
4
|
+
export { resolveClock, CLOCK_MEMBERS } from './zone.js';
|
|
5
|
+
export { compileBuckets, resampleSeries, RESAMPLE_MEMBERS } from './bucket.js';
|
|
6
|
+
export { rollingSeries, ROLLING_MEMBERS } from './rolling.js';
|
|
7
|
+
export { asOfJoin, ASOF_MEMBERS } from './asof.js';
|
|
8
|
+
export { downsampleSeries, DOWNSAMPLE_MEMBERS } from './downsample.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type IntervalIndex = {
|
|
2
|
+
/**
|
|
3
|
+
* - how many intervals were indexed
|
|
4
|
+
*/
|
|
5
|
+
size: number;
|
|
6
|
+
/**
|
|
7
|
+
* - the items whose
|
|
8
|
+
* `[start, end)` contains this instant
|
|
9
|
+
*/
|
|
10
|
+
at: (at: number | string) => any[];
|
|
11
|
+
/**
|
|
12
|
+
* - the items sharing an instant with `[start, end)`
|
|
13
|
+
*/
|
|
14
|
+
overlapping: (start: number | string, end: number | string) => any[];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Build a queryable index over `[start, end)` intervals.
|
|
18
|
+
*
|
|
19
|
+
* Both queries answer with the caller's own items, ascending by start
|
|
20
|
+
* and — for items sharing a start — in the order they were given. Every
|
|
21
|
+
* result is a fresh array, so a caller can sort or splice it without
|
|
22
|
+
* reaching into the index.
|
|
23
|
+
*
|
|
24
|
+
* Bounds are read once at build time through the selectors, as epoch
|
|
25
|
+
* milliseconds or RFC 3339 strings, and an interval that is empty,
|
|
26
|
+
* reversed or names no instant is refused here rather than being
|
|
27
|
+
* skipped: an index quietly holding fewer rows than it was given
|
|
28
|
+
* answers every later question wrongly.
|
|
29
|
+
*
|
|
30
|
+
* @param {any[]} items - the rows, in any order
|
|
31
|
+
* @param {Object} [selectors]
|
|
32
|
+
* @param {string | ((item: any, index: number) => any)} [selectors.start]
|
|
33
|
+
* where the lower bound lives (default `'start'`)
|
|
34
|
+
* @param {string | ((item: any, index: number) => any)} [selectors.end]
|
|
35
|
+
* where the upper bound lives (default `'end'`)
|
|
36
|
+
* @returns {IntervalIndex}
|
|
37
|
+
* @throws {TypeError} for a non-array, a row that is not an object, a
|
|
38
|
+
* bound that names no instant, or `end <= start`
|
|
39
|
+
* @example
|
|
40
|
+
* const index = createIntervalIndex(bookings, { start: 'from', end: 'to' });
|
|
41
|
+
* index.at('2026-03-01T10:00:00Z'); // who is booked then
|
|
42
|
+
* index.overlapping(dayStart, dayEnd); // everything touching today
|
|
43
|
+
*/
|
|
44
|
+
export declare function createIntervalIndex(items: any[], selectors?: {
|
|
45
|
+
start?: string | ((item: any, index: number) => any);
|
|
46
|
+
end?: string | ((item: any, index: number) => any);
|
|
47
|
+
}): IntervalIndex;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export type Interval = import('./normalize.js').Interval;
|
|
2
|
+
/**
|
|
3
|
+
* Does `interval` contain the instant `at`?
|
|
4
|
+
*
|
|
5
|
+
* Half-open: the start is in, the end is out. An instant on a boundary
|
|
6
|
+
* belongs to exactly one of two touching intervals, which is what makes
|
|
7
|
+
* "which shift is this event in" answerable.
|
|
8
|
+
*
|
|
9
|
+
* @param {Interval} interval
|
|
10
|
+
* @param {number | string} at - epoch milliseconds or RFC 3339
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
13
|
+
* @example
|
|
14
|
+
* containsInstant({ start: 0, end: 10 }, 0); // true
|
|
15
|
+
* containsInstant({ start: 0, end: 10 }, 10); // false
|
|
16
|
+
*/
|
|
17
|
+
export declare function containsInstant(interval: Interval, at: number | string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Do two intervals share at least one instant?
|
|
20
|
+
*
|
|
21
|
+
* Touching intervals do not: `[0, 10)` and `[10, 20)` have no instant
|
|
22
|
+
* in common, so consecutive bookings never read as a conflict.
|
|
23
|
+
*
|
|
24
|
+
* @param {Interval} a
|
|
25
|
+
* @param {Interval} b
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
28
|
+
* @example
|
|
29
|
+
* overlapsInterval({ start: 0, end: 10 }, { start: 10, end: 20 }); // false
|
|
30
|
+
* overlapsInterval({ start: 0, end: 10 }, { start: 9, end: 20 }); // true
|
|
31
|
+
*/
|
|
32
|
+
export declare function overlapsInterval(a: Interval, b: Interval): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The span two intervals share, or `null` when they share none.
|
|
35
|
+
*
|
|
36
|
+
* `null` rather than an empty interval, because `[t, t)` is not a value
|
|
37
|
+
* this algebra has — the absence is the answer, and it cannot then be
|
|
38
|
+
* fed back in as if it were a span.
|
|
39
|
+
*
|
|
40
|
+
* @param {Interval} a
|
|
41
|
+
* @param {Interval} b
|
|
42
|
+
* @returns {Interval | null} a new record
|
|
43
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
44
|
+
* @example
|
|
45
|
+
* intersectInterval({ start: 0, end: 10 }, { start: 5, end: 20 });
|
|
46
|
+
* // { start: 5, end: 10 }
|
|
47
|
+
*/
|
|
48
|
+
export declare function intersectInterval(a: Interval, b: Interval): Interval | null;
|
|
49
|
+
/**
|
|
50
|
+
* `mergeIntervals`' closed specification: whether spans that touch
|
|
51
|
+
* join, which availability normally wants and a handover does not.
|
|
52
|
+
*/
|
|
53
|
+
export declare const MERGE_MEMBERS: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* The union of `intervals`, as the fewest disjoint spans that cover the
|
|
56
|
+
* same instants, ascending.
|
|
57
|
+
*
|
|
58
|
+
* Touching spans are joined by default, because continuous cover is
|
|
59
|
+
* what availability means; `{ adjacent: false }` keeps them apart, which
|
|
60
|
+
* is what a handover between two shifts means. Overlapping spans always
|
|
61
|
+
* join, under both settings.
|
|
62
|
+
*
|
|
63
|
+
* @param {Interval[]} intervals - in any order
|
|
64
|
+
* @param {Object} [options]
|
|
65
|
+
* @param {boolean} [options.adjacent] - join touching spans (default `true`)
|
|
66
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
67
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
68
|
+
* @example
|
|
69
|
+
* mergeIntervals([{ start: 0, end: 10 }, { start: 10, end: 20 }]);
|
|
70
|
+
* // [{ start: 0, end: 20 }]
|
|
71
|
+
* mergeIntervals([{ start: 0, end: 10 }, { start: 10, end: 20 }],
|
|
72
|
+
* { adjacent: false });
|
|
73
|
+
* // [{ start: 0, end: 10 }, { start: 10, end: 20 }]
|
|
74
|
+
*/
|
|
75
|
+
export declare function mergeIntervals(intervals: Interval[], options?: {
|
|
76
|
+
adjacent?: boolean;
|
|
77
|
+
}): Interval[];
|
|
78
|
+
/**
|
|
79
|
+
* The instants in `from` that `remove` does not cover, as disjoint
|
|
80
|
+
* ascending spans.
|
|
81
|
+
*
|
|
82
|
+
* Both sides are merged first, so the result is the set difference and
|
|
83
|
+
* nothing depends on the order the arguments arrived in. A cut through
|
|
84
|
+
* the middle of a span **splits** it into two; a cut that covers a span
|
|
85
|
+
* removes it entirely.
|
|
86
|
+
*
|
|
87
|
+
* @param {Interval[]} from - the spans being reduced
|
|
88
|
+
* @param {Interval[]} remove - the spans taken out of them
|
|
89
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
90
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
91
|
+
* @example
|
|
92
|
+
* subtractIntervals([{ start: 0, end: 100 }], [{ start: 40, end: 60 }]);
|
|
93
|
+
* // [{ start: 0, end: 40 }, { start: 60, end: 100 }]
|
|
94
|
+
*/
|
|
95
|
+
export declare function subtractIntervals(from: Interval[], remove: Interval[]): Interval[];
|
|
96
|
+
/**
|
|
97
|
+
* The spans inside `within` that `intervals` leaves uncovered.
|
|
98
|
+
*
|
|
99
|
+
* `within` defaults to the hull of the intervals themselves — the gaps
|
|
100
|
+
* *between* them — because the alternative default would be a clock,
|
|
101
|
+
* and this kernel has none. Passing it explicitly is what reports a
|
|
102
|
+
* missing edge: an empty morning before the first booking is only a gap
|
|
103
|
+
* if the caller says the day starts at nine.
|
|
104
|
+
*
|
|
105
|
+
* @param {Interval[]} intervals - in any order
|
|
106
|
+
* @param {Interval} [within] - the window to look inside
|
|
107
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
108
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
109
|
+
* @example
|
|
110
|
+
* gapsWithin([{ start: 0, end: 10 }, { start: 30, end: 40 }]);
|
|
111
|
+
* // [{ start: 10, end: 30 }]
|
|
112
|
+
* gapsWithin([{ start: 10, end: 20 }], { start: 0, end: 30 });
|
|
113
|
+
* // [{ start: 0, end: 10 }, { start: 20, end: 30 }]
|
|
114
|
+
*/
|
|
115
|
+
export declare function gapsWithin(intervals: Interval[], within?: Interval): Interval[];
|
|
116
|
+
/**
|
|
117
|
+
* How many milliseconds `intervals` cover, counting an instant once
|
|
118
|
+
* however many spans hold it.
|
|
119
|
+
*
|
|
120
|
+
* Restricted to `within` when given, which is what turns it into a
|
|
121
|
+
* ratio: `coverageOf(shifts, day) / (day.end - day.start)` is the
|
|
122
|
+
* fraction of the day that is staffed.
|
|
123
|
+
*
|
|
124
|
+
* @param {Interval[]} intervals - in any order
|
|
125
|
+
* @param {Interval} [within] - clip to this window first
|
|
126
|
+
* @returns {number} milliseconds
|
|
127
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
128
|
+
* @example
|
|
129
|
+
* coverageOf([{ start: 0, end: 10 }, { start: 5, end: 20 }]); // 20
|
|
130
|
+
*/
|
|
131
|
+
export declare function coverageOf(intervals: Interval[], within?: Interval): number;
|
|
132
|
+
/**
|
|
133
|
+
* `findSlots`' closed specification: how long a slot is and how far
|
|
134
|
+
* apart two of them start. Enumeration, never a constraint solver.
|
|
135
|
+
*/
|
|
136
|
+
export declare const SLOTS_MEMBERS: readonly string[];
|
|
137
|
+
/**
|
|
138
|
+
* Every place a span of `duration` fits inside `availability`.
|
|
139
|
+
*
|
|
140
|
+
* Availability is merged first — two touching windows are one window,
|
|
141
|
+
* so a meeting may straddle the seam — and each merged window is then
|
|
142
|
+
* walked from its own start in `step` increments (default: back to
|
|
143
|
+
* back), keeping every span that still ends inside the window. A window
|
|
144
|
+
* exactly one duration wide yields exactly one slot.
|
|
145
|
+
*
|
|
146
|
+
* This is enumeration, not scheduling: it answers "where could this
|
|
147
|
+
* go", and choosing among the answers, weighing preferences or
|
|
148
|
+
* assigning people is a solver's job, deliberately not this one.
|
|
149
|
+
*
|
|
150
|
+
* `duration` and `step` are fixed widths — a number of milliseconds or
|
|
151
|
+
* a fixed ISO 8601 duration (`'PT30M'`). A calendar duration is refused
|
|
152
|
+
* rather than approximated, because "every month" needs a calendar and
|
|
153
|
+
* a zone to say where its boundaries fall.
|
|
154
|
+
*
|
|
155
|
+
* @param {Interval[]} availability - in any order
|
|
156
|
+
* @param {Object} spec
|
|
157
|
+
* @param {number | string} spec.duration - how long the span is
|
|
158
|
+
* @param {number | string} [spec.step] - the spacing between starts
|
|
159
|
+
* (default: `duration`)
|
|
160
|
+
* @returns {Interval[]} new `{ start, end }` records, ascending
|
|
161
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval, or
|
|
162
|
+
* a duration/step that is not a positive fixed width
|
|
163
|
+
* @example
|
|
164
|
+
* findSlots([{ start: 0, end: 90 }], { duration: 60, step: 30 });
|
|
165
|
+
* // [{ start: 0, end: 60 }, { start: 30, end: 90 }]
|
|
166
|
+
*/
|
|
167
|
+
export declare function findSlots(availability: Interval[], spec: {
|
|
168
|
+
duration: number | string;
|
|
169
|
+
step?: number | string;
|
|
170
|
+
}): Interval[];
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export type Sample = {
|
|
2
|
+
/**
|
|
3
|
+
* - Unix epoch milliseconds
|
|
4
|
+
*/
|
|
5
|
+
at: number;
|
|
6
|
+
/**
|
|
7
|
+
* - the reading, or `null` for a
|
|
8
|
+
* measured gap
|
|
9
|
+
*/
|
|
10
|
+
value: number | null;
|
|
11
|
+
};
|
|
12
|
+
export type Interval = {
|
|
13
|
+
/**
|
|
14
|
+
* - inclusive lower bound, epoch milliseconds
|
|
15
|
+
*/
|
|
16
|
+
start: number;
|
|
17
|
+
/**
|
|
18
|
+
* - exclusive upper bound, epoch milliseconds
|
|
19
|
+
*/
|
|
20
|
+
end: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} Sample
|
|
24
|
+
* @property {number} at - Unix epoch milliseconds
|
|
25
|
+
* @property {number | null} value - the reading, or `null` for a
|
|
26
|
+
* measured gap
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} Interval
|
|
30
|
+
* @property {number} start - inclusive lower bound, epoch milliseconds
|
|
31
|
+
* @property {number} end - exclusive upper bound, epoch milliseconds
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The instant `value` names, in Unix epoch milliseconds.
|
|
35
|
+
*
|
|
36
|
+
* Accepts the two forms a date has in this suite: a finite **number**
|
|
37
|
+
* (already epoch milliseconds, returned unchanged) and a valid **RFC
|
|
38
|
+
* 3339 string**. A full-date reads as UTC midnight, an offset shifts to
|
|
39
|
+
* the instant it names, and `Z` is UTC.
|
|
40
|
+
*
|
|
41
|
+
* Refused, all as `TypeError`: a full-time (`'09:30:00Z'` names no day,
|
|
42
|
+
* and inventing one would be a hidden clock), a date-time without an
|
|
43
|
+
* offset (`'2026-01-01T09:30:00'` names no instant without a zone),
|
|
44
|
+
* `NaN`/`Infinity`, a `Date` object (dates are strings or numbers here,
|
|
45
|
+
* never a wrapper), and anything else.
|
|
46
|
+
*
|
|
47
|
+
* @param {number | string} value
|
|
48
|
+
* @returns {number} epoch milliseconds
|
|
49
|
+
* @throws {TypeError} when `value` names no instant
|
|
50
|
+
* @example
|
|
51
|
+
* toEpoch(0); // 0
|
|
52
|
+
* toEpoch('1970-01-01'); // 0
|
|
53
|
+
* toEpoch('1970-01-01T01:00:00+01:00'); // 0
|
|
54
|
+
*/
|
|
55
|
+
export declare function toEpoch(value: number | string): number;
|
|
56
|
+
/**
|
|
57
|
+
* One instant read out of a row, with the row named when it is not one.
|
|
58
|
+
* "not an RFC 3339 instant" is a puzzle when ten thousand rows were
|
|
59
|
+
* handed in; "row 4172, at: …" is a defect someone can go and look at.
|
|
60
|
+
*
|
|
61
|
+
* @param {any} value - the raw member
|
|
62
|
+
* @param {string} role - the member's name, for the message
|
|
63
|
+
* @param {number} index - the row's position
|
|
64
|
+
* @returns {number} epoch milliseconds
|
|
65
|
+
* @throws {TypeError} when `value` names no instant
|
|
66
|
+
*/
|
|
67
|
+
export declare function epochAt(value: any, role: string, index: number): number;
|
|
68
|
+
/**
|
|
69
|
+
* Sorted canonical samples: every row's instant converted once, its
|
|
70
|
+
* value checked, its other members kept, and the whole ascending by
|
|
71
|
+
* instant.
|
|
72
|
+
*
|
|
73
|
+
* The sort is **stable**, so rows sharing an instant come out in input
|
|
74
|
+
* order — and both are present, because a duplicate instant is two
|
|
75
|
+
* readings, not one row written twice.
|
|
76
|
+
*
|
|
77
|
+
* Each result is a shallow copy of its source row with canonical `at`
|
|
78
|
+
* and `value` members written over it, so a source that spelled its
|
|
79
|
+
* instant `on` keeps `on` too and nothing a caller attached is lost.
|
|
80
|
+
*
|
|
81
|
+
* @param {any[]} rows - the source rows
|
|
82
|
+
* @param {Object} [options]
|
|
83
|
+
* @param {string | ((item: any, index: number) => any)} [options.at]
|
|
84
|
+
* where the instant lives (default `'at'`)
|
|
85
|
+
* @param {string | ((item: any, index: number) => any)} [options.value]
|
|
86
|
+
* where the reading lives (default `'value'`)
|
|
87
|
+
* @returns {(Sample & Record<string, any>)[]} a new array of new records
|
|
88
|
+
* @throws {TypeError} for a non-array, a row that is not an object, an
|
|
89
|
+
* instant that names none, or a value that is neither a finite number
|
|
90
|
+
* nor `null`
|
|
91
|
+
* @example
|
|
92
|
+
* normalizeSeries([{ on: '2026-01-01T00:00:01Z', v: 2 },
|
|
93
|
+
* { on: '2026-01-01T00:00:00Z', v: 1 }],
|
|
94
|
+
* { at: 'on', value: 'v' });
|
|
95
|
+
* // [{ on: '…:00Z', v: 1, at: 1767225600000, value: 1 },
|
|
96
|
+
* // { on: '…:01Z', v: 2, at: 1767225601000, value: 2 }]
|
|
97
|
+
*/
|
|
98
|
+
export declare function normalizeSeries(rows: any[], options?: {
|
|
99
|
+
at?: string | ((item: any, index: number) => any);
|
|
100
|
+
value?: string | ((item: any, index: number) => any);
|
|
101
|
+
}): (Sample & Record<string, any>)[];
|
|
102
|
+
/**
|
|
103
|
+
* The same canonical samples, without the copy when there is nothing to
|
|
104
|
+
* convert.
|
|
105
|
+
*
|
|
106
|
+
* {@link normalizeSeries} always builds a new array of new records,
|
|
107
|
+
* which is the right answer at the door and the wrong one three kernels
|
|
108
|
+
* later: bucketing, rolling and joining all take a series that a caller
|
|
109
|
+
* usually normalized once already, and re-copying a hundred thousand
|
|
110
|
+
* rows per operation costs more than the operation. So this checks
|
|
111
|
+
* instead of converting — one pass, no allocation — and hands the
|
|
112
|
+
* caller's own array straight back when every row is already
|
|
113
|
+
* `{ at: <finite number>, value: <number | null> }` and ascending.
|
|
114
|
+
*
|
|
115
|
+
* Nothing is trusted: a row that fails the check sends the whole array
|
|
116
|
+
* through {@link normalizeSeries}, which refuses it there with the row
|
|
117
|
+
* named. The fast path is a measurement, not a promise.
|
|
118
|
+
*
|
|
119
|
+
* @param {any[]} rows
|
|
120
|
+
* @param {Object} [options]
|
|
121
|
+
* @param {string | ((item: any, index: number) => any)} [options.at]
|
|
122
|
+
* @param {string | ((item: any, index: number) => any)} [options.value]
|
|
123
|
+
* @returns {(Sample & Record<string, any>)[]} `rows` itself, or a new
|
|
124
|
+
* normalized array
|
|
125
|
+
* @throws {TypeError} exactly where {@link normalizeSeries} does
|
|
126
|
+
*/
|
|
127
|
+
export declare function canonicalSeries(rows: any[], options?: {
|
|
128
|
+
at?: string | ((item: any, index: number) => any);
|
|
129
|
+
value?: string | ((item: any, index: number) => any);
|
|
130
|
+
}): (Sample & Record<string, any>)[];
|
|
131
|
+
/**
|
|
132
|
+
* Sorted canonical intervals: every bound converted once, the direction
|
|
133
|
+
* checked, other members kept, and the whole ascending by start.
|
|
134
|
+
*
|
|
135
|
+
* Half-open `[start, end)` with `start < end` is the contract the whole
|
|
136
|
+
* algebra rests on, so an empty (`start === end`), reversed or
|
|
137
|
+
* non-finite interval is refused here rather than producing an answer
|
|
138
|
+
* later that no reader could predict. Duplicates survive — two bookings
|
|
139
|
+
* of the same slot are two bookings — and rows sharing a start keep
|
|
140
|
+
* their input order.
|
|
141
|
+
*
|
|
142
|
+
* @param {any[]} rows - the source rows
|
|
143
|
+
* @param {Object} [options]
|
|
144
|
+
* @param {string | ((item: any, index: number) => any)} [options.start]
|
|
145
|
+
* where the lower bound lives (default `'start'`)
|
|
146
|
+
* @param {string | ((item: any, index: number) => any)} [options.end]
|
|
147
|
+
* where the upper bound lives (default `'end'`)
|
|
148
|
+
* @returns {(Interval & Record<string, any>)[]} a new array of new records
|
|
149
|
+
* @throws {TypeError} for a non-array, a row that is not an object, a
|
|
150
|
+
* bound that names no instant, or `end <= start`
|
|
151
|
+
* @example
|
|
152
|
+
* normalizeIntervals([{ start: '2026-01-01', end: '2026-01-02' }]);
|
|
153
|
+
* // [{ start: 1767225600000, end: 1767312000000 }]
|
|
154
|
+
*/
|
|
155
|
+
export declare function normalizeIntervals(rows: any[], options?: {
|
|
156
|
+
start?: string | ((item: any, index: number) => any);
|
|
157
|
+
end?: string | ((item: any, index: number) => any);
|
|
158
|
+
}): (Interval & Record<string, any>)[];
|
|
159
|
+
/**
|
|
160
|
+
* The index of the first row at or after `at` — the lower end of a
|
|
161
|
+
* half-open cut. `rows` must already be ascending on `key`.
|
|
162
|
+
*
|
|
163
|
+
* This is the whole reason a series is normalized once: a range over a
|
|
164
|
+
* sorted array is two binary searches and a slice, not a pass over
|
|
165
|
+
* everything.
|
|
166
|
+
*
|
|
167
|
+
* @param {any[]} rows - ascending on `key`
|
|
168
|
+
* @param {number} at - epoch milliseconds
|
|
169
|
+
* @param {string} [key] - the member holding the instant (default `'at'`)
|
|
170
|
+
* @returns {number} an index in `[0, rows.length]`
|
|
171
|
+
* @example
|
|
172
|
+
* const lo = lowerBoundTime(samples, start);
|
|
173
|
+
* const hi = lowerBoundTime(samples, end);
|
|
174
|
+
* samples.slice(lo, hi); // every sample in [start, end)
|
|
175
|
+
*/
|
|
176
|
+
export declare function lowerBoundTime(rows: any[], at: number, key?: string): number;
|
|
177
|
+
/**
|
|
178
|
+
* The index of the first row strictly after `at`. `rows` must already
|
|
179
|
+
* be ascending on `key`.
|
|
180
|
+
*
|
|
181
|
+
* The twin of {@link lowerBoundTime}: together they bracket the rows AT
|
|
182
|
+
* an instant (`[lowerBoundTime(rows, t), upperBoundTime(rows, t))`),
|
|
183
|
+
* which is what a duplicate-tolerant as-of has to read.
|
|
184
|
+
*
|
|
185
|
+
* @param {any[]} rows - ascending on `key`
|
|
186
|
+
* @param {number} at - epoch milliseconds
|
|
187
|
+
* @param {string} [key] - the member holding the instant (default `'at'`)
|
|
188
|
+
* @returns {number} an index in `[0, rows.length]`
|
|
189
|
+
*/
|
|
190
|
+
export declare function upperBoundTime(rows: any[], at: number, key?: string): number;
|