@nowline/layout 0.8.6 → 0.10.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/axis-columns.d.ts +70 -0
- package/dist/axis-columns.d.ts.map +1 -0
- package/dist/axis-columns.js +104 -0
- package/dist/axis-columns.js.map +1 -0
- package/dist/calendar-resolver.d.ts +27 -0
- package/dist/calendar-resolver.d.ts.map +1 -0
- package/dist/calendar-resolver.js +132 -0
- package/dist/calendar-resolver.js.map +1 -0
- package/dist/calendar.d.ts +0 -19
- package/dist/calendar.d.ts.map +1 -1
- package/dist/calendar.js +4 -44
- package/dist/calendar.js.map +1 -1
- package/dist/canvas-width.d.ts +23 -0
- package/dist/canvas-width.d.ts.map +1 -0
- package/dist/canvas-width.js +26 -0
- package/dist/canvas-width.js.map +1 -0
- package/dist/capacity.d.ts +28 -0
- package/dist/capacity.d.ts.map +1 -1
- package/dist/capacity.js +64 -29
- package/dist/capacity.js.map +1 -1
- package/dist/chart-translation.d.ts +22 -0
- package/dist/chart-translation.d.ts.map +1 -0
- package/dist/chart-translation.js +83 -0
- package/dist/chart-translation.js.map +1 -0
- package/dist/container-header-geometry.d.ts +64 -0
- package/dist/container-header-geometry.d.ts.map +1 -0
- package/dist/container-header-geometry.js +89 -0
- package/dist/container-header-geometry.js.map +1 -0
- package/dist/dsl-utils.d.ts +3 -4
- package/dist/dsl-utils.d.ts.map +1 -1
- package/dist/dsl-utils.js +8 -25
- package/dist/dsl-utils.js.map +1 -1
- package/dist/edge-routing.d.ts +69 -78
- package/dist/edge-routing.d.ts.map +1 -1
- package/dist/edge-routing.js +271 -401
- package/dist/edge-routing.js.map +1 -1
- package/dist/frame-tab-geometry.d.ts +24 -14
- package/dist/frame-tab-geometry.d.ts.map +1 -1
- package/dist/frame-tab-geometry.js +69 -23
- package/dist/frame-tab-geometry.js.map +1 -1
- package/dist/header-card-geometry.d.ts +12 -0
- package/dist/header-card-geometry.d.ts.map +1 -1
- package/dist/header-card-geometry.js +14 -2
- package/dist/header-card-geometry.js.map +1 -1
- package/dist/header-logo.d.ts +28 -0
- package/dist/header-logo.d.ts.map +1 -0
- package/dist/header-logo.js +51 -0
- package/dist/header-logo.js.map +1 -0
- package/dist/i18n.d.ts +11 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +11 -1
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +20 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +1 -1
- package/dist/inline-date-pin-geometry.d.ts +19 -11
- package/dist/inline-date-pin-geometry.d.ts.map +1 -1
- package/dist/inline-date-pin-geometry.js +45 -46
- package/dist/inline-date-pin-geometry.js.map +1 -1
- package/dist/item-bar-geometry.d.ts +192 -4
- package/dist/item-bar-geometry.d.ts.map +1 -1
- package/dist/item-bar-geometry.js +258 -5
- package/dist/item-bar-geometry.js.map +1 -1
- package/dist/item-port-geometry.d.ts +13 -0
- package/dist/item-port-geometry.d.ts.map +1 -0
- package/dist/item-port-geometry.js +63 -0
- package/dist/item-port-geometry.js.map +1 -0
- package/dist/layout-context.d.ts +115 -51
- package/dist/layout-context.d.ts.map +1 -1
- package/dist/layout-context.js +96 -5
- package/dist/layout-context.js.map +1 -1
- package/dist/layout-insights.d.ts.map +1 -1
- package/dist/layout-insights.js +73 -1
- package/dist/layout-insights.js.map +1 -1
- package/dist/layout.d.ts +35 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +18 -1315
- package/dist/layout.js.map +1 -1
- package/dist/nodes/anchor-node.d.ts.map +1 -1
- package/dist/nodes/anchor-node.js +11 -4
- package/dist/nodes/anchor-node.js.map +1 -1
- package/dist/nodes/content-extent.d.ts +24 -0
- package/dist/nodes/content-extent.d.ts.map +1 -0
- package/dist/nodes/content-extent.js +100 -0
- package/dist/nodes/content-extent.js.map +1 -0
- package/dist/nodes/dependency-edges.d.ts +6 -0
- package/dist/nodes/dependency-edges.d.ts.map +1 -0
- package/dist/nodes/dependency-edges.js +148 -0
- package/dist/nodes/dependency-edges.js.map +1 -0
- package/dist/nodes/footnote-node.d.ts +24 -3
- package/dist/nodes/footnote-node.d.ts.map +1 -1
- package/dist/nodes/footnote-node.js +129 -25
- package/dist/nodes/footnote-node.js.map +1 -1
- package/dist/nodes/group-node.d.ts +10 -21
- package/dist/nodes/group-node.d.ts.map +1 -1
- package/dist/nodes/group-node.js +79 -152
- package/dist/nodes/group-node.js.map +1 -1
- package/dist/nodes/header-node.d.ts +56 -0
- package/dist/nodes/header-node.d.ts.map +1 -0
- package/dist/nodes/header-node.js +163 -0
- package/dist/nodes/header-node.js.map +1 -0
- package/dist/nodes/include-node.d.ts +22 -12
- package/dist/nodes/include-node.d.ts.map +1 -1
- package/dist/nodes/include-node.js +95 -62
- package/dist/nodes/include-node.js.map +1 -1
- package/dist/nodes/item-measure.d.ts +135 -0
- package/dist/nodes/item-measure.d.ts.map +1 -0
- package/dist/nodes/item-measure.js +514 -0
- package/dist/nodes/item-measure.js.map +1 -0
- package/dist/nodes/item-node.d.ts +162 -9
- package/dist/nodes/item-node.d.ts.map +1 -1
- package/dist/nodes/item-node.js +256 -69
- package/dist/nodes/item-node.js.map +1 -1
- package/dist/nodes/marker-band.d.ts +118 -0
- package/dist/nodes/marker-band.d.ts.map +1 -0
- package/dist/nodes/marker-band.js +303 -0
- package/dist/nodes/marker-band.js.map +1 -0
- package/dist/nodes/marker-geometry.js +1 -1
- package/dist/nodes/marker-geometry.js.map +1 -1
- package/dist/nodes/milestone-node.d.ts +112 -15
- package/dist/nodes/milestone-node.d.ts.map +1 -1
- package/dist/nodes/milestone-node.js +246 -95
- package/dist/nodes/milestone-node.js.map +1 -1
- package/dist/nodes/nowline-node.d.ts +8 -0
- package/dist/nodes/nowline-node.d.ts.map +1 -0
- package/dist/nodes/nowline-node.js +74 -0
- package/dist/nodes/nowline-node.js.map +1 -0
- package/dist/nodes/parallel-node.d.ts +7 -12
- package/dist/nodes/parallel-node.d.ts.map +1 -1
- package/dist/nodes/parallel-node.js +66 -30
- package/dist/nodes/parallel-node.js.map +1 -1
- package/dist/nodes/region-markers.d.ts +41 -0
- package/dist/nodes/region-markers.d.ts.map +1 -0
- package/dist/nodes/region-markers.js +130 -0
- package/dist/nodes/region-markers.js.map +1 -0
- package/dist/nodes/roadmap-node.d.ts +9 -72
- package/dist/nodes/roadmap-node.d.ts.map +1 -1
- package/dist/nodes/roadmap-node.js +38 -788
- package/dist/nodes/roadmap-node.js.map +1 -1
- package/dist/nodes/row-track.d.ts +33 -0
- package/dist/nodes/row-track.d.ts.map +1 -0
- package/dist/nodes/row-track.js +170 -0
- package/dist/nodes/row-track.js.map +1 -0
- package/dist/nodes/sequence-item.d.ts +25 -0
- package/dist/nodes/sequence-item.d.ts.map +1 -0
- package/dist/nodes/sequence-item.js +396 -0
- package/dist/nodes/sequence-item.js.map +1 -0
- package/dist/nodes/sequence-one.d.ts +5 -0
- package/dist/nodes/sequence-one.d.ts.map +1 -0
- package/dist/nodes/sequence-one.js +26 -0
- package/dist/nodes/sequence-one.js.map +1 -0
- package/dist/nodes/swimlane-node.d.ts +5 -17
- package/dist/nodes/swimlane-node.d.ts.map +1 -1
- package/dist/nodes/swimlane-node.js +62 -145
- package/dist/nodes/swimlane-node.js.map +1 -1
- package/dist/nodes/wave-node.d.ts +52 -0
- package/dist/nodes/wave-node.d.ts.map +1 -0
- package/dist/nodes/wave-node.js +483 -0
- package/dist/nodes/wave-node.js.map +1 -0
- package/dist/non-working-display.d.ts +14 -0
- package/dist/non-working-display.d.ts.map +1 -0
- package/dist/non-working-display.js +28 -0
- package/dist/non-working-display.js.map +1 -0
- package/dist/phases/date-window.d.ts +48 -0
- package/dist/phases/date-window.d.ts.map +1 -0
- package/dist/phases/date-window.js +130 -0
- package/dist/phases/date-window.js.map +1 -0
- package/dist/phases/finalize.d.ts +198 -0
- package/dist/phases/finalize.d.ts.map +1 -0
- package/dist/phases/finalize.js +1058 -0
- package/dist/phases/finalize.js.map +1 -0
- package/dist/phases/measure.d.ts +77 -0
- package/dist/phases/measure.d.ts.map +1 -0
- package/dist/phases/measure.js +222 -0
- package/dist/phases/measure.js.map +1 -0
- package/dist/phases/place.d.ts +28 -0
- package/dist/phases/place.d.ts.map +1 -0
- package/dist/phases/place.js +225 -0
- package/dist/phases/place.js.map +1 -0
- package/dist/renderable.d.ts.map +1 -1
- package/dist/renderable.js +23 -3
- package/dist/renderable.js.map +1 -1
- package/dist/resolve-today.d.ts +11 -1
- package/dist/resolve-today.d.ts.map +1 -1
- package/dist/resolve-today.js +26 -12
- package/dist/resolve-today.js.map +1 -1
- package/dist/row-packer.d.ts +42 -16
- package/dist/row-packer.d.ts.map +1 -1
- package/dist/row-packer.js +51 -78
- package/dist/row-packer.js.map +1 -1
- package/dist/schedule.d.ts +58 -1
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +280 -106
- package/dist/schedule.js.map +1 -1
- package/dist/start-rules.d.ts +27 -0
- package/dist/start-rules.d.ts.map +1 -0
- package/dist/start-rules.js +91 -0
- package/dist/start-rules.js.map +1 -0
- package/dist/start-terms.d.ts +23 -0
- package/dist/start-terms.d.ts.map +1 -0
- package/dist/start-terms.js +27 -0
- package/dist/start-terms.js.map +1 -0
- package/dist/status-dot.d.ts +46 -0
- package/dist/status-dot.d.ts.map +1 -0
- package/dist/status-dot.js +78 -0
- package/dist/status-dot.js.map +1 -0
- package/dist/style-resolution.d.ts.map +1 -1
- package/dist/style-resolution.js +23 -43
- package/dist/style-resolution.js.map +1 -1
- package/dist/text-measure.d.ts +28 -0
- package/dist/text-measure.d.ts.map +1 -0
- package/dist/text-measure.js +70 -0
- package/dist/text-measure.js.map +1 -0
- package/dist/themes/contrast.d.ts +4 -0
- package/dist/themes/contrast.d.ts.map +1 -0
- package/dist/themes/contrast.js +32 -0
- package/dist/themes/contrast.js.map +1 -0
- package/dist/themes/dark.d.ts.map +1 -1
- package/dist/themes/dark.js +28 -1
- package/dist/themes/dark.js.map +1 -1
- package/dist/themes/grayscale.d.ts.map +1 -1
- package/dist/themes/grayscale.js +28 -1
- package/dist/themes/grayscale.js.map +1 -1
- package/dist/themes/index.d.ts +4 -11
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +6 -4
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/light.d.ts.map +1 -1
- package/dist/themes/light.js +28 -1
- package/dist/themes/light.js.map +1 -1
- package/dist/themes/shape.d.ts +31 -0
- package/dist/themes/shape.d.ts.map +1 -1
- package/dist/themes/shared.d.ts +44 -50
- package/dist/themes/shared.d.ts.map +1 -1
- package/dist/themes/shared.js +79 -83
- package/dist/themes/shared.js.map +1 -1
- package/dist/time-scale.d.ts +42 -6
- package/dist/time-scale.d.ts.map +1 -1
- package/dist/time-scale.js +109 -17
- package/dist/time-scale.js.map +1 -1
- package/dist/types.d.ts +528 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/view-preset.d.ts +61 -6
- package/dist/view-preset.d.ts.map +1 -1
- package/dist/view-preset.js +439 -24
- package/dist/view-preset.js.map +1 -1
- package/dist/wave-barrier.d.ts +107 -0
- package/dist/wave-barrier.d.ts.map +1 -0
- package/dist/wave-barrier.js +174 -0
- package/dist/wave-barrier.js.map +1 -0
- package/dist/wave-hatch.d.ts +4 -0
- package/dist/wave-hatch.d.ts.map +1 -0
- package/dist/wave-hatch.js +21 -0
- package/dist/wave-hatch.js.map +1 -0
- package/dist/wave-layout.d.ts +99 -0
- package/dist/wave-layout.d.ts.map +1 -0
- package/dist/wave-layout.js +130 -0
- package/dist/wave-layout.js.map +1 -0
- package/dist/working-calendar.d.ts +128 -4
- package/dist/working-calendar.d.ts.map +1 -1
- package/dist/working-calendar.js +725 -51
- package/dist/working-calendar.js.map +1 -1
- package/dist/working-span.d.ts +11 -0
- package/dist/working-span.d.ts.map +1 -0
- package/dist/working-span.js +18 -0
- package/dist/working-span.js.map +1 -0
- package/package.json +4 -4
- package/src/axis-columns.ts +140 -0
- package/src/calendar-resolver.ts +161 -0
- package/src/calendar.ts +4 -45
- package/src/canvas-width.ts +36 -0
- package/src/capacity.ts +71 -30
- package/src/chart-translation.ts +88 -0
- package/src/container-header-geometry.ts +116 -0
- package/src/dsl-utils.ts +8 -26
- package/src/edge-routing.ts +347 -491
- package/src/frame-tab-geometry.ts +78 -25
- package/src/header-card-geometry.ts +17 -2
- package/src/header-logo.ts +67 -0
- package/src/i18n.ts +22 -1
- package/src/index.ts +97 -12
- package/src/inline-date-pin-geometry.ts +55 -58
- package/src/item-bar-geometry.ts +341 -5
- package/src/item-port-geometry.ts +69 -0
- package/src/layout-context.ts +210 -72
- package/src/layout-insights.ts +109 -2
- package/src/layout.ts +57 -1530
- package/src/nodes/anchor-node.ts +11 -4
- package/src/nodes/content-extent.ts +107 -0
- package/src/nodes/dependency-edges.ts +171 -0
- package/src/nodes/footnote-node.ts +171 -31
- package/src/nodes/group-node.ts +92 -214
- package/src/nodes/header-node.ts +255 -0
- package/src/nodes/include-node.ts +124 -110
- package/src/nodes/item-measure.ts +723 -0
- package/src/nodes/item-node.ts +358 -80
- package/src/nodes/marker-band.ts +406 -0
- package/src/nodes/marker-geometry.ts +1 -1
- package/src/nodes/milestone-node.ts +321 -136
- package/src/nodes/nowline-node.ts +87 -0
- package/src/nodes/parallel-node.ts +69 -38
- package/src/nodes/region-markers.ts +194 -0
- package/src/nodes/roadmap-node.ts +46 -950
- package/src/nodes/row-track.ts +217 -0
- package/src/nodes/sequence-item.ts +433 -0
- package/src/nodes/sequence-one.ts +40 -0
- package/src/nodes/swimlane-node.ts +66 -205
- package/src/nodes/wave-node.ts +609 -0
- package/src/non-working-display.ts +32 -0
- package/src/phases/date-window.ts +191 -0
- package/src/phases/finalize.ts +1303 -0
- package/src/phases/measure.ts +329 -0
- package/src/phases/place.ts +318 -0
- package/src/renderable.ts +23 -3
- package/src/resolve-today.ts +28 -25
- package/src/row-packer.ts +85 -84
- package/src/schedule.ts +362 -114
- package/src/start-rules.ts +113 -0
- package/src/start-terms.ts +38 -0
- package/src/status-dot.ts +81 -0
- package/src/style-resolution.ts +27 -57
- package/src/text-measure.ts +71 -0
- package/src/themes/contrast.ts +34 -0
- package/src/themes/dark.ts +28 -1
- package/src/themes/grayscale.ts +28 -1
- package/src/themes/index.ts +10 -7
- package/src/themes/light.ts +28 -1
- package/src/themes/shape.ts +38 -0
- package/src/themes/shared.ts +85 -93
- package/src/time-scale.ts +117 -19
- package/src/types.ts +529 -20
- package/src/view-preset.ts +520 -27
- package/src/wave-barrier.ts +242 -0
- package/src/wave-hatch.ts +25 -0
- package/src/wave-layout.ts +203 -0
- package/src/working-calendar.ts +875 -57
- package/src/working-span.ts +19 -0
package/src/working-calendar.ts
CHANGED
|
@@ -1,74 +1,261 @@
|
|
|
1
|
-
// WorkingCalendar
|
|
2
|
-
//
|
|
3
|
-
// rendering pipeline treats time as continuous; the existing
|
|
4
|
-
// `CalendarConfig` only changes how `1w` literals expand into days,
|
|
5
|
-
// not whether ticks skip weekends. WorkingCalendar establishes the
|
|
6
|
-
// API surface for the future weekend/holiday work without making
|
|
7
|
-
// `TimeScale` and `ViewPreset` aware of `CalendarConfig` directly.
|
|
1
|
+
// WorkingCalendar: the two jobs a calendar does (specs/working-calendar.md
|
|
2
|
+
// §3.1).
|
|
8
3
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
4
|
+
// 1. Duration arithmetic. `daysPerUnit` turns `1w` / `1m` / `1q` / `1y` into
|
|
5
|
+
// a count of working days from the `CalendarConfig` preset. The
|
|
6
|
+
// non-working rules never change it.
|
|
7
|
+
// 2. Non-working days. A list of `CalendarRule`s (the preset weekend plus
|
|
8
|
+
// holidays, closures, recurring windows and working exceptions) decides
|
|
9
|
+
// which UTC dates consume no work. `workingIndexOf` and
|
|
10
|
+
// `dateAtWorkingIndex` map between dates and a working-day index;
|
|
11
|
+
// `nonWorkingRuns` lists the maximal non-working stretches to draw, and
|
|
12
|
+
// `workingExceptionRuns` the days titled `working` rules put back.
|
|
13
|
+
//
|
|
14
|
+
// Precedence is resolved once, at construction, into a sorted list of
|
|
15
|
+
// disjoint pieces covering every day: a weekly mask (the open-ended week or
|
|
16
|
+
// a bounded recurrence pattern) or an all-working / all-non-working dated
|
|
17
|
+
// stretch. Queries cost O(pieces in the span) with whole-week arithmetic
|
|
18
|
+
// inside a piece; nothing walks day by day except the one-off pass over the
|
|
19
|
+
// finite days of titled dated `working` rules.
|
|
20
|
+
//
|
|
21
|
+
// Identity guarantee: a calendar with no non-working day (`calendar:full`,
|
|
22
|
+
// `calendar:custom`, `continuousCalendar()`, or rules that cancel out)
|
|
23
|
+
// delegates to `daysBetween` / `addDays`, so its answers, including the
|
|
24
|
+
// rounding and `addDays`' truncation of fractional days, match continuous
|
|
25
|
+
// calendar-day time exactly.
|
|
26
|
+
|
|
27
|
+
import type { CalendarConfig, CalendarMode } from './calendar.js';
|
|
28
|
+
import { addDays, daysBetween } from './calendar.js';
|
|
17
29
|
import type { ScaleUnit } from './view-preset.js';
|
|
18
30
|
|
|
31
|
+
/**
|
|
32
|
+
* One `non-working` (or working-exception) declaration. Kinds:
|
|
33
|
+
* `dates`, or `start` + `end` without `every`, is a dated rule; `every` with
|
|
34
|
+
* `start` and/or `end` is a bounded recurrence (open on an omitted side);
|
|
35
|
+
* `every` alone is the open-ended week. All dates are UTC midnight.
|
|
36
|
+
*/
|
|
37
|
+
export interface CalendarRule {
|
|
38
|
+
/** False: weekend, holiday, closure. True: a working exception. */
|
|
39
|
+
working: boolean;
|
|
40
|
+
/** Recurring UTC weekdays (0 = Sunday … 6 = Saturday). */
|
|
41
|
+
every?: ReadonlySet<number>;
|
|
42
|
+
/** Specific dates (`date:` single or list); excludes `every` / `start` / `end`. */
|
|
43
|
+
dates?: ReadonlyArray<Date>;
|
|
44
|
+
/** Range start, or recurrence window start; inclusive. */
|
|
45
|
+
start?: Date;
|
|
46
|
+
/** Range end, or recurrence window end; inclusive. */
|
|
47
|
+
end?: Date;
|
|
48
|
+
id?: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A maximal run of consecutive non-working dates. */
|
|
53
|
+
export interface NonWorkingRun {
|
|
54
|
+
/** First non-working date of the run (UTC midnight). */
|
|
55
|
+
from: Date;
|
|
56
|
+
/** Last non-working date of the run, inclusive. */
|
|
57
|
+
through: Date;
|
|
58
|
+
/** Titles of the dated non-working rules inside it; empty for a plain weekend. */
|
|
59
|
+
titles: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A maximal run of consecutive days that titled dated `working` rules turn
|
|
64
|
+
* from non-working to working (specs/working-calendar.md §7.4): days a
|
|
65
|
+
* dated closure, a bounded recurrence or the open-ended week would close.
|
|
66
|
+
*/
|
|
67
|
+
export interface WorkingExceptionRun {
|
|
68
|
+
/** First day of the run (UTC midnight). */
|
|
69
|
+
from: Date;
|
|
70
|
+
/** Last day of the run (UTC midnight), inclusive. */
|
|
71
|
+
through: Date;
|
|
72
|
+
/** Titles of the titled dated working rules that turn a day of it, never empty. */
|
|
73
|
+
titles: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
19
76
|
export interface WorkingCalendar {
|
|
20
77
|
/** Days per `1<unit>` literal (e.g. `1w` → 5 for business). */
|
|
21
78
|
daysPerUnit(unit: ScaleUnit): number;
|
|
22
|
-
/**
|
|
79
|
+
/** `dateAtWorkingIndex(date, count × daysPerUnit(unit))`. */
|
|
23
80
|
addUnits(date: Date, count: number, unit: ScaleUnit): Date;
|
|
24
|
-
/**
|
|
81
|
+
/** False: every function below is plain calendar-day math. */
|
|
82
|
+
readonly hasNonWorkingDays: boolean;
|
|
83
|
+
/** True when the given date is a working day; an invalid date counts as working. */
|
|
25
84
|
isWorkingDay(date: Date): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Working days in [base, date), signed. A non-working `date` counts as the
|
|
87
|
+
* next working day. `base` must be UTC midnight.
|
|
88
|
+
*/
|
|
89
|
+
workingIndexOf(base: Date, date: Date): number;
|
|
90
|
+
/**
|
|
91
|
+
* UTC midnight of the working day with this index from `base` (fractions
|
|
92
|
+
* floor). Index 0 is the first working day at or after `base`, which must
|
|
93
|
+
* be UTC midnight.
|
|
94
|
+
*/
|
|
95
|
+
dateAtWorkingIndex(base: Date, index: number): Date;
|
|
96
|
+
/** Maximal non-working runs intersecting [from, to), extended past both ends. */
|
|
97
|
+
nonWorkingRuns(from: Date, to: Date): NonWorkingRun[];
|
|
98
|
+
/** Working-exception runs intersecting [from, to), with their real dates. */
|
|
99
|
+
workingExceptionRuns(from: Date, to: Date): WorkingExceptionRun[];
|
|
100
|
+
/**
|
|
101
|
+
* First working weekday after the longest recurring non-working run of the
|
|
102
|
+
* open-ended week (Sat–Sun → Monday); undefined without one.
|
|
103
|
+
*/
|
|
104
|
+
readonly weekStart: number | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Weekdays the open-ended week works (0 = Sunday … 6 = Saturday): the
|
|
107
|
+
* weekly pattern an exporter writes as its base calendar. Dated rules and
|
|
108
|
+
* bounded recurrences are exceptions to it and never change it.
|
|
109
|
+
*/
|
|
110
|
+
readonly workingWeekdays: ReadonlySet<number>;
|
|
111
|
+
/** Normalized copies of the valid rules, in input order. */
|
|
112
|
+
readonly rules: ReadonlyArray<CalendarRule>;
|
|
26
113
|
}
|
|
27
114
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
115
|
+
/** The rules a calendar preset implies: the weekend for business, nothing otherwise. */
|
|
116
|
+
export function presetRules(mode: CalendarMode): CalendarRule[] {
|
|
117
|
+
if (mode !== 'business') return [];
|
|
118
|
+
return [{ working: false, every: new Set([0, 6]), id: 'weekend', title: 'Weekend' }];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the working calendar for a resolved `CalendarConfig`. `rules`
|
|
123
|
+
* defaults to the preset's (the weekend for business). Malformed rules are
|
|
124
|
+
* dropped silently because included files are parsed without validation;
|
|
125
|
+
* throws `RangeError` for a weekday outside 0..6 or an unbounded stretch
|
|
126
|
+
* with no working weekday.
|
|
127
|
+
*/
|
|
128
|
+
export function fromCalendarConfig(
|
|
129
|
+
cal: CalendarConfig,
|
|
130
|
+
rules: ReadonlyArray<CalendarRule> = presetRules(cal.mode),
|
|
131
|
+
): WorkingCalendar {
|
|
132
|
+
return buildCalendar((unit) => daysPerUnitForCalendar(unit, cal), rules);
|
|
38
133
|
}
|
|
39
134
|
|
|
135
|
+
/** Every day is a working day; units use the full-calendar day counts. */
|
|
40
136
|
export function continuousCalendar(): WorkingCalendar {
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
137
|
+
return buildCalendar(continuousDaysPerUnit, []);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The exclusive end date of a span of working-day indices [s, e) from
|
|
142
|
+
* `base` (specs/working-calendar.md §5.2): the day after the last whole
|
|
143
|
+
* working day the span covers, so a Mon–Fri item ends on Saturday. A span
|
|
144
|
+
* covering no whole working day ends on its start date. Indices floor, as
|
|
145
|
+
* in `dateAtWorkingIndex`.
|
|
146
|
+
*
|
|
147
|
+
* On the identity path it is `addDays(base, e)`, truncation included, so
|
|
148
|
+
* calendar-day spans keep their exact dates.
|
|
149
|
+
*/
|
|
150
|
+
export function spanEndDate(calendar: WorkingCalendar, base: Date, s: number, e: number): Date {
|
|
151
|
+
if (!calendar.hasNonWorkingDays) return addDays(base, e);
|
|
152
|
+
const last = Math.floor(e);
|
|
153
|
+
if (last <= Math.floor(s)) return calendar.dateAtWorkingIndex(base, s);
|
|
154
|
+
return addDays(calendar.dateAtWorkingIndex(base, last - 1), 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A maximal run of consecutive days on which the calendar differs from its
|
|
159
|
+
* open-ended week (specs/working-calendar.md §8): a holiday, a closure, a
|
|
160
|
+
* bounded recurrence's closed days, or a working day inside a closed weekend.
|
|
161
|
+
* The MS Project `<Exceptions>` and the Mermaid dated `excludes` both read it.
|
|
162
|
+
*/
|
|
163
|
+
export interface CalendarException {
|
|
164
|
+
/** First day of the run (UTC midnight). */
|
|
165
|
+
from: Date;
|
|
166
|
+
/** Last day of the run (UTC midnight), inclusive. */
|
|
167
|
+
through: Date;
|
|
168
|
+
/** True: a working day the open week closes. False: a closure the open week works. */
|
|
169
|
+
working: boolean;
|
|
170
|
+
/** Titles of the dated or ranged rules behind it; omitted when there are none. */
|
|
171
|
+
titles?: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The days in the horizon whose working state differs from the open-ended
|
|
176
|
+
* week (`workingWeekdays`), as maximal runs split wherever the state flips.
|
|
177
|
+
*
|
|
178
|
+
* The horizon is [from, to) together with every finite bound of every rule:
|
|
179
|
+
* its `dates`, and its `start` / `end` when set. A half-open bounded
|
|
180
|
+
* recurrence is therefore expanded only inside that horizon, so a declared
|
|
181
|
+
* holiday outside the roadmap window is still listed and nothing iterates
|
|
182
|
+
* forever. Titles come from the dated or ranged rules (no `every`) whose
|
|
183
|
+
* `working` equals the run's state and that cover a day of it, in
|
|
184
|
+
* `working.rules` order, de-duplicated.
|
|
185
|
+
*/
|
|
186
|
+
export function calendarExceptions(
|
|
187
|
+
working: WorkingCalendar,
|
|
188
|
+
from: Date,
|
|
189
|
+
to: Date,
|
|
190
|
+
): CalendarException[] {
|
|
191
|
+
const windows: DaySpan[] = [];
|
|
192
|
+
const add = (start: number, end: number) => {
|
|
193
|
+
if (!Number.isNaN(start) && !Number.isNaN(end) && start < end) windows.push({ start, end });
|
|
194
|
+
};
|
|
195
|
+
add(dayNumber(from), dayNumber(to));
|
|
196
|
+
for (const rule of working.rules) {
|
|
197
|
+
for (const date of rule.dates ?? []) add(dayNumber(date), dayNumber(date) + 1);
|
|
198
|
+
const start = rule.start === undefined ? undefined : dayNumber(rule.start);
|
|
199
|
+
const end = rule.end === undefined ? undefined : dayNumber(rule.end);
|
|
200
|
+
if (start !== undefined) add(start, start + 1);
|
|
201
|
+
if (end !== undefined) add(end, end + 1);
|
|
202
|
+
if (start !== undefined && end !== undefined) add(start, end + 1);
|
|
203
|
+
}
|
|
204
|
+
windows.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
205
|
+
|
|
206
|
+
const out: CalendarException[] = [];
|
|
207
|
+
let open: { from: number; through: number; working: boolean } | undefined;
|
|
208
|
+
const close = () => {
|
|
209
|
+
if (!open) return;
|
|
210
|
+
const titles = exceptionTitles(working.rules, open.from, open.through, open.working);
|
|
211
|
+
out.push({
|
|
212
|
+
from: dateOfDay(open.from),
|
|
213
|
+
through: dateOfDay(open.through),
|
|
214
|
+
working: open.working,
|
|
215
|
+
...(titles.length > 0 ? { titles } : {}),
|
|
216
|
+
});
|
|
217
|
+
open = undefined;
|
|
71
218
|
};
|
|
219
|
+
|
|
220
|
+
let day = -Infinity;
|
|
221
|
+
for (const window of windows) {
|
|
222
|
+
for (let d = Math.max(day, window.start); d < window.end; d++) {
|
|
223
|
+
const state = working.isWorkingDay(dateOfDay(d));
|
|
224
|
+
if (state === working.workingWeekdays.has(weekdayOf(d))) {
|
|
225
|
+
close();
|
|
226
|
+
} else if (open && open.through === d - 1 && open.working === state) {
|
|
227
|
+
open.through = d;
|
|
228
|
+
} else {
|
|
229
|
+
close();
|
|
230
|
+
open = { from: d, through: d, working: state };
|
|
231
|
+
}
|
|
232
|
+
day = d + 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
close();
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Titles of the dated or ranged rules of `state` that cover a day in [from, through]. */
|
|
240
|
+
function exceptionTitles(
|
|
241
|
+
rules: ReadonlyArray<CalendarRule>,
|
|
242
|
+
from: number,
|
|
243
|
+
through: number,
|
|
244
|
+
state: boolean,
|
|
245
|
+
): string[] {
|
|
246
|
+
const titles: string[] = [];
|
|
247
|
+
for (const rule of rules) {
|
|
248
|
+
if (rule.every !== undefined || rule.working !== state || rule.title === undefined)
|
|
249
|
+
continue;
|
|
250
|
+
const covers = rule.dates
|
|
251
|
+
? rule.dates.some((date) => dayNumber(date) >= from && dayNumber(date) <= through)
|
|
252
|
+
: rule.start !== undefined &&
|
|
253
|
+
rule.end !== undefined &&
|
|
254
|
+
dayNumber(rule.start) <= through &&
|
|
255
|
+
dayNumber(rule.end) >= from;
|
|
256
|
+
if (covers && !titles.includes(rule.title)) titles.push(rule.title);
|
|
257
|
+
}
|
|
258
|
+
return titles;
|
|
72
259
|
}
|
|
73
260
|
|
|
74
261
|
export function daysPerUnit(unit: ScaleUnit, cal: CalendarConfig): number {
|
|
@@ -89,3 +276,634 @@ function daysPerUnitForCalendar(unit: ScaleUnit, cal: CalendarConfig): number {
|
|
|
89
276
|
return cal.daysPerYear;
|
|
90
277
|
}
|
|
91
278
|
}
|
|
279
|
+
|
|
280
|
+
/** Calendar days per `1<unit>` when every day counts (`calendar:full`). */
|
|
281
|
+
export function continuousDaysPerUnit(unit: ScaleUnit): number {
|
|
282
|
+
switch (unit) {
|
|
283
|
+
case 'days':
|
|
284
|
+
return 1;
|
|
285
|
+
case 'weeks':
|
|
286
|
+
return 7;
|
|
287
|
+
case 'months':
|
|
288
|
+
return 30;
|
|
289
|
+
case 'quarters':
|
|
290
|
+
return 91;
|
|
291
|
+
case 'years':
|
|
292
|
+
return 365;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Construction.
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
const DAY_MS = 86_400_000;
|
|
301
|
+
const DAYS_PER_WEEK = 7;
|
|
302
|
+
/** Day 0 (1970-01-01) is a Thursday. */
|
|
303
|
+
const EPOCH_WEEKDAY = 4;
|
|
304
|
+
/** Beyond this many working days every result is outside the Date range. */
|
|
305
|
+
const MAX_WORKING_INDEX = 1e8;
|
|
306
|
+
/** A remainder scan inside one weekly piece needs at most 7 steps. */
|
|
307
|
+
const MAX_REMAINDER_STEPS = 14;
|
|
308
|
+
|
|
309
|
+
type UnitDays = (unit: ScaleUnit) => number;
|
|
310
|
+
|
|
311
|
+
/** A day span [start, end) in UTC day numbers; either side may be infinite. */
|
|
312
|
+
interface DaySpan {
|
|
313
|
+
start: number;
|
|
314
|
+
end: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface RecurringSpan extends DaySpan {
|
|
318
|
+
working: boolean;
|
|
319
|
+
mask: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface TitledRule {
|
|
323
|
+
title: string;
|
|
324
|
+
spans: DaySpan[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface NormalizedRule {
|
|
328
|
+
copy: CalendarRule;
|
|
329
|
+
working: boolean;
|
|
330
|
+
/** Weekday bitmask; undefined for a dated rule. */
|
|
331
|
+
mask?: number;
|
|
332
|
+
spans: DaySpan[];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
type Piece =
|
|
336
|
+
| (DaySpan & { kind: 'mask'; off: readonly boolean[]; workingPerWeek: number })
|
|
337
|
+
| (DaySpan & { kind: 'working' | 'non-working' });
|
|
338
|
+
|
|
339
|
+
interface MaskSegment extends DaySpan {
|
|
340
|
+
mask: number;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface DatedSegment extends DaySpan {
|
|
344
|
+
kind: 'working' | 'non-working';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function buildCalendar(unitDays: UnitDays, input: ReadonlyArray<CalendarRule>): WorkingCalendar {
|
|
348
|
+
const normalized: NormalizedRule[] = [];
|
|
349
|
+
for (const rule of input) {
|
|
350
|
+
const result = normalizeRule(rule);
|
|
351
|
+
if (result) normalized.push(result);
|
|
352
|
+
}
|
|
353
|
+
const rules = normalized.map((rule) => rule.copy);
|
|
354
|
+
|
|
355
|
+
const openMask = openWeekMask(normalized);
|
|
356
|
+
const segments = recurringSegments(openMask, boundedSpans(normalized));
|
|
357
|
+
const pieces = overlay(segments, datedSegments(normalized));
|
|
358
|
+
const exceptionRuns = titledWorkingRuns(normalized, segments);
|
|
359
|
+
const workingExceptionRuns = (from: Date, to: Date): WorkingExceptionRun[] =>
|
|
360
|
+
runsIntersecting(exceptionRuns, from, to);
|
|
361
|
+
const first = pieces[0];
|
|
362
|
+
const last = pieces[pieces.length - 1];
|
|
363
|
+
for (const piece of [first, last]) {
|
|
364
|
+
if (piece.kind !== 'mask' || piece.workingPerWeek === 0) {
|
|
365
|
+
throw new RangeError('working calendar: an unbounded stretch has no working weekday');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const hasNonWorkingDays = pieces.some(
|
|
369
|
+
(piece) =>
|
|
370
|
+
piece.kind === 'non-working' ||
|
|
371
|
+
(piece.kind === 'mask' && piece.workingPerWeek < DAYS_PER_WEEK),
|
|
372
|
+
);
|
|
373
|
+
const workingWeekdays = weekdaysOutside(openMask);
|
|
374
|
+
if (!hasNonWorkingDays) {
|
|
375
|
+
return identityCalendar(unitDays, rules, workingWeekdays, workingExceptionRuns);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const index = new WorkingDayIndex(pieces, titledRules(normalized, false));
|
|
379
|
+
const dateAtWorkingIndex = (base: Date, workingIndex: number): Date =>
|
|
380
|
+
index.dateAt(base, workingIndex);
|
|
381
|
+
return {
|
|
382
|
+
rules,
|
|
383
|
+
hasNonWorkingDays: true,
|
|
384
|
+
weekStart: weekStartOf(openMask),
|
|
385
|
+
workingWeekdays,
|
|
386
|
+
daysPerUnit: unitDays,
|
|
387
|
+
addUnits: (date, count, unit) => dateAtWorkingIndex(date, count * unitDays(unit)),
|
|
388
|
+
isWorkingDay: (date) => index.isWorkingDay(date),
|
|
389
|
+
workingIndexOf: (base, date) => index.indexOf(base, date),
|
|
390
|
+
dateAtWorkingIndex,
|
|
391
|
+
nonWorkingRuns: (from, to) => index.runs(from, to),
|
|
392
|
+
workingExceptionRuns,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function identityCalendar(
|
|
397
|
+
unitDays: UnitDays,
|
|
398
|
+
rules: ReadonlyArray<CalendarRule>,
|
|
399
|
+
workingWeekdays: ReadonlySet<number>,
|
|
400
|
+
workingExceptionRuns: WorkingCalendar['workingExceptionRuns'],
|
|
401
|
+
): WorkingCalendar {
|
|
402
|
+
return {
|
|
403
|
+
rules,
|
|
404
|
+
hasNonWorkingDays: false,
|
|
405
|
+
weekStart: undefined,
|
|
406
|
+
workingWeekdays,
|
|
407
|
+
daysPerUnit: unitDays,
|
|
408
|
+
addUnits: (date, count, unit) => addDays(date, count * unitDays(unit)),
|
|
409
|
+
isWorkingDay: () => true,
|
|
410
|
+
workingIndexOf: (base, date) => daysBetween(base, date),
|
|
411
|
+
dateAtWorkingIndex: (base, index) => addDays(base, index),
|
|
412
|
+
nonWorkingRuns: () => [],
|
|
413
|
+
workingExceptionRuns,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Validate and deep-copy one rule; undefined drops it. */
|
|
418
|
+
function normalizeRule(rule: CalendarRule): NormalizedRule | undefined {
|
|
419
|
+
const mask = rule.every === undefined ? undefined : weekdayMask(rule.every);
|
|
420
|
+
const hasBound = rule.start !== undefined || rule.end !== undefined;
|
|
421
|
+
if (mask === 0) return undefined;
|
|
422
|
+
if (rule.dates !== undefined && (mask !== undefined || hasBound)) return undefined;
|
|
423
|
+
const isRange = mask === undefined && rule.dates === undefined;
|
|
424
|
+
if (isRange && (rule.start === undefined || rule.end === undefined)) return undefined;
|
|
425
|
+
|
|
426
|
+
const start = rule.start === undefined ? -Infinity : dayNumber(rule.start);
|
|
427
|
+
const end = rule.end === undefined ? Infinity : dayNumber(rule.end);
|
|
428
|
+
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return undefined;
|
|
429
|
+
const dates = rule.dates?.map(dayNumber);
|
|
430
|
+
if (dates?.some(Number.isNaN)) return undefined;
|
|
431
|
+
|
|
432
|
+
const spans = dates
|
|
433
|
+
? dates.map((day) => ({ start: day, end: day + 1 }))
|
|
434
|
+
: [{ start, end: end + 1 }];
|
|
435
|
+
return { copy: copyRule(rule), working: rule.working, mask, spans };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function copyRule(rule: CalendarRule): CalendarRule {
|
|
439
|
+
const copy: CalendarRule = { working: rule.working };
|
|
440
|
+
if (rule.every !== undefined) copy.every = new Set(rule.every);
|
|
441
|
+
if (rule.dates !== undefined) copy.dates = rule.dates.map((date) => new Date(date.getTime()));
|
|
442
|
+
if (rule.start !== undefined) copy.start = new Date(rule.start.getTime());
|
|
443
|
+
if (rule.end !== undefined) copy.end = new Date(rule.end.getTime());
|
|
444
|
+
if (rule.id !== undefined) copy.id = rule.id;
|
|
445
|
+
if (rule.title !== undefined) copy.title = rule.title;
|
|
446
|
+
return copy;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function weekdayMask(every: ReadonlySet<number>): number {
|
|
450
|
+
let mask = 0;
|
|
451
|
+
for (const weekday of every) {
|
|
452
|
+
if (!Number.isInteger(weekday) || weekday < 0 || weekday >= DAYS_PER_WEEK) {
|
|
453
|
+
throw new RangeError(`working calendar: weekday ${weekday} is outside 0..6`);
|
|
454
|
+
}
|
|
455
|
+
mask |= 1 << weekday;
|
|
456
|
+
}
|
|
457
|
+
return mask;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The open-ended week: non-working weekdays minus open-ended working ones. */
|
|
461
|
+
function openWeekMask(rules: ReadonlyArray<NormalizedRule>): number {
|
|
462
|
+
let off = 0;
|
|
463
|
+
let working = 0;
|
|
464
|
+
for (const rule of rules) {
|
|
465
|
+
if (rule.mask === undefined || !isOpenEnded(rule.spans[0])) continue;
|
|
466
|
+
if (rule.working) working |= rule.mask;
|
|
467
|
+
else off |= rule.mask;
|
|
468
|
+
}
|
|
469
|
+
return off & ~working;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The weekdays a mask leaves working. */
|
|
473
|
+
function weekdaysOutside(mask: number): ReadonlySet<number> {
|
|
474
|
+
const out = new Set<number>();
|
|
475
|
+
for (let weekday = 0; weekday < DAYS_PER_WEEK; weekday++) {
|
|
476
|
+
if ((mask & (1 << weekday)) === 0) out.add(weekday);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isOpenEnded(span: DaySpan): boolean {
|
|
482
|
+
return span.start === -Infinity && span.end === Infinity;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function boundedSpans(rules: ReadonlyArray<NormalizedRule>): RecurringSpan[] {
|
|
486
|
+
const out: RecurringSpan[] = [];
|
|
487
|
+
for (const rule of rules) {
|
|
488
|
+
if (rule.mask === undefined || isOpenEnded(rule.spans[0])) continue;
|
|
489
|
+
out.push({ ...rule.spans[0], working: rule.working, mask: rule.mask });
|
|
490
|
+
}
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Split the line into segments with one weekly mask each: the open-ended
|
|
496
|
+
* week, overridden by every active bounded recurrence (working wins ties).
|
|
497
|
+
*/
|
|
498
|
+
function recurringSegments(openMask: number, bounded: ReadonlyArray<RecurringSpan>): MaskSegment[] {
|
|
499
|
+
const offCount = new Array<number>(DAYS_PER_WEEK).fill(0);
|
|
500
|
+
const workingCount = new Array<number>(DAYS_PER_WEEK).fill(0);
|
|
501
|
+
const apply = (span: RecurringSpan, delta: number) => {
|
|
502
|
+
const counts = span.working ? workingCount : offCount;
|
|
503
|
+
for (let weekday = 0; weekday < DAYS_PER_WEEK; weekday++) {
|
|
504
|
+
if (span.mask & (1 << weekday)) counts[weekday] += delta;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
const currentMask = () => {
|
|
508
|
+
let mask = openMask;
|
|
509
|
+
for (let weekday = 0; weekday < DAYS_PER_WEEK; weekday++) {
|
|
510
|
+
if (offCount[weekday] > 0) mask |= 1 << weekday;
|
|
511
|
+
if (workingCount[weekday] > 0) mask &= ~(1 << weekday);
|
|
512
|
+
}
|
|
513
|
+
return mask;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const events: Array<{ at: number; span: RecurringSpan; delta: number }> = [];
|
|
517
|
+
for (const span of bounded) {
|
|
518
|
+
if (span.start === -Infinity) apply(span, 1);
|
|
519
|
+
else events.push({ at: span.start, span, delta: 1 });
|
|
520
|
+
if (span.end !== Infinity) events.push({ at: span.end, span, delta: -1 });
|
|
521
|
+
}
|
|
522
|
+
events.sort((a, b) => a.at - b.at);
|
|
523
|
+
|
|
524
|
+
const segments: MaskSegment[] = [];
|
|
525
|
+
let start = -Infinity;
|
|
526
|
+
let next = 0;
|
|
527
|
+
for (;;) {
|
|
528
|
+
const end = next < events.length ? events[next].at : Infinity;
|
|
529
|
+
if (start < end) pushMaskSegment(segments, { start, end, mask: currentMask() });
|
|
530
|
+
if (end === Infinity) return segments;
|
|
531
|
+
while (next < events.length && events[next].at === end) {
|
|
532
|
+
apply(events[next].span, events[next].delta);
|
|
533
|
+
next++;
|
|
534
|
+
}
|
|
535
|
+
start = end;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function pushMaskSegment(segments: MaskSegment[], segment: MaskSegment): void {
|
|
540
|
+
const previous = segments[segments.length - 1];
|
|
541
|
+
if (previous && previous.mask === segment.mask) previous.end = segment.end;
|
|
542
|
+
else segments.push(segment);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Disjoint dated stretches, each all-working or all-non-working (working wins ties). */
|
|
546
|
+
function datedSegments(rules: ReadonlyArray<NormalizedRule>): DatedSegment[] {
|
|
547
|
+
const events: Array<{ at: number; working: boolean; delta: number }> = [];
|
|
548
|
+
for (const rule of rules) {
|
|
549
|
+
if (rule.mask !== undefined) continue;
|
|
550
|
+
for (const span of rule.spans) {
|
|
551
|
+
events.push({ at: span.start, working: rule.working, delta: 1 });
|
|
552
|
+
events.push({ at: span.end, working: rule.working, delta: -1 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
events.sort((a, b) => a.at - b.at);
|
|
556
|
+
|
|
557
|
+
const segments: DatedSegment[] = [];
|
|
558
|
+
let working = 0;
|
|
559
|
+
let off = 0;
|
|
560
|
+
let next = 0;
|
|
561
|
+
while (next < events.length) {
|
|
562
|
+
const start = events[next].at;
|
|
563
|
+
while (next < events.length && events[next].at === start) {
|
|
564
|
+
if (events[next].working) working += events[next].delta;
|
|
565
|
+
else off += events[next].delta;
|
|
566
|
+
next++;
|
|
567
|
+
}
|
|
568
|
+
if (next === events.length || (working === 0 && off === 0)) continue;
|
|
569
|
+
const kind = working > 0 ? 'working' : 'non-working';
|
|
570
|
+
const end = events[next].at;
|
|
571
|
+
const previous = segments[segments.length - 1];
|
|
572
|
+
if (previous && previous.kind === kind && previous.end === start) previous.end = end;
|
|
573
|
+
else segments.push({ start, end, kind });
|
|
574
|
+
}
|
|
575
|
+
return segments;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Lay the dated stretches over the weekly segments; dated rules beat recurring ones. */
|
|
579
|
+
function overlay(
|
|
580
|
+
segments: ReadonlyArray<MaskSegment>,
|
|
581
|
+
dated: ReadonlyArray<DatedSegment>,
|
|
582
|
+
): Piece[] {
|
|
583
|
+
const pieces: Piece[] = [];
|
|
584
|
+
let first = 0;
|
|
585
|
+
for (const segment of segments) {
|
|
586
|
+
while (first < dated.length && dated[first].end <= segment.start) first++;
|
|
587
|
+
let cursor = segment.start;
|
|
588
|
+
for (let i = first; i < dated.length && dated[i].start < segment.end; i++) {
|
|
589
|
+
const start = Math.max(dated[i].start, segment.start);
|
|
590
|
+
const end = Math.min(dated[i].end, segment.end);
|
|
591
|
+
if (cursor < start) pieces.push(maskPiece(cursor, start, segment.mask));
|
|
592
|
+
pieces.push({ start, end, kind: dated[i].kind });
|
|
593
|
+
cursor = end;
|
|
594
|
+
}
|
|
595
|
+
if (cursor < segment.end) pieces.push(maskPiece(cursor, segment.end, segment.mask));
|
|
596
|
+
}
|
|
597
|
+
return pieces;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function maskPiece(start: number, end: number, mask: number): Piece {
|
|
601
|
+
const off: boolean[] = [];
|
|
602
|
+
for (let weekday = 0; weekday < DAYS_PER_WEEK; weekday++) {
|
|
603
|
+
off.push((mask & (1 << weekday)) !== 0);
|
|
604
|
+
}
|
|
605
|
+
const workingPerWeek = off.filter((isOff) => !isOff).length;
|
|
606
|
+
return { start, end, kind: 'mask', off, workingPerWeek };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Titled dated rules of one state, by first date then input order. */
|
|
610
|
+
function titledRules(rules: ReadonlyArray<NormalizedRule>, working: boolean): TitledRule[] {
|
|
611
|
+
const titled: Array<TitledRule & { first: number; order: number }> = [];
|
|
612
|
+
for (const [order, rule] of rules.entries()) {
|
|
613
|
+
const title = rule.copy.title;
|
|
614
|
+
if (rule.mask !== undefined || rule.working !== working || title === undefined) continue;
|
|
615
|
+
if (rule.spans.length === 0) continue;
|
|
616
|
+
const first = rule.spans.reduce((min, span) => Math.min(min, span.start), Infinity);
|
|
617
|
+
titled.push({ title, spans: rule.spans, first, order });
|
|
618
|
+
}
|
|
619
|
+
titled.sort((a, b) => a.first - b.first || a.order - b.order);
|
|
620
|
+
return titled.map(({ title, spans }) => ({ title, spans }));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The runs of days a titled dated `working` rule turns from non-working to
|
|
625
|
+
* working, under the precedence of specs/working-calendar.md §4.1. A dated
|
|
626
|
+
* working rule wins every day it covers, so it turns a day exactly when the
|
|
627
|
+
* calendar without the dated working rules (the weekly segments under the
|
|
628
|
+
* dated closures) closes it. That catches a weekday make-up day inside a
|
|
629
|
+
* dated closure, which `calendarExceptions` cannot see because the open
|
|
630
|
+
* week works it. Dated rules are finite, so this walks their days once.
|
|
631
|
+
*/
|
|
632
|
+
function titledWorkingRuns(
|
|
633
|
+
rules: ReadonlyArray<NormalizedRule>,
|
|
634
|
+
segments: ReadonlyArray<MaskSegment>,
|
|
635
|
+
): WorkingExceptionRun[] {
|
|
636
|
+
const titled = titledRules(rules, true);
|
|
637
|
+
if (titled.length === 0) return [];
|
|
638
|
+
const closures = rules.filter((rule) => !rule.working);
|
|
639
|
+
const baseline = overlay(segments, datedSegments(closures));
|
|
640
|
+
|
|
641
|
+
// Each turned day, with the indices (into `titled`) of the rules that turn it.
|
|
642
|
+
const turned = new Map<number, Set<number>>();
|
|
643
|
+
titled.forEach((rule, i) => {
|
|
644
|
+
for (const span of rule.spans) {
|
|
645
|
+
for (let day = span.start; day < span.end; day++) {
|
|
646
|
+
if (!isNonWorkingIn(baseline, day)) continue;
|
|
647
|
+
const owners = turned.get(day) ?? new Set<number>();
|
|
648
|
+
owners.add(i);
|
|
649
|
+
turned.set(day, owners);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const days = [...turned.keys()].sort((a, b) => a - b);
|
|
655
|
+
const out: WorkingExceptionRun[] = [];
|
|
656
|
+
for (let i = 0; i < days.length; ) {
|
|
657
|
+
let j = i;
|
|
658
|
+
const owners = new Set<number>();
|
|
659
|
+
while (j < days.length && days[j] - days[i] === j - i) {
|
|
660
|
+
for (const owner of turned.get(days[j]) ?? []) owners.add(owner);
|
|
661
|
+
j++;
|
|
662
|
+
}
|
|
663
|
+
out.push({
|
|
664
|
+
from: dateOfDay(days[i]),
|
|
665
|
+
through: dateOfDay(days[j - 1]),
|
|
666
|
+
titles: [...owners].sort((a, b) => a - b).map((owner) => titled[owner].title),
|
|
667
|
+
});
|
|
668
|
+
i = j;
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Copies of the runs with a day in [from, to). */
|
|
674
|
+
function runsIntersecting(
|
|
675
|
+
runs: ReadonlyArray<WorkingExceptionRun>,
|
|
676
|
+
fromDate: Date,
|
|
677
|
+
toDate: Date,
|
|
678
|
+
): WorkingExceptionRun[] {
|
|
679
|
+
const from = dayNumber(fromDate);
|
|
680
|
+
const to = dayNumber(toDate);
|
|
681
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return [];
|
|
682
|
+
return runs
|
|
683
|
+
.filter((run) => dayNumber(run.from) < to && dayNumber(run.through) >= from)
|
|
684
|
+
.map((run) => ({
|
|
685
|
+
from: new Date(run.from.getTime()),
|
|
686
|
+
through: new Date(run.through.getTime()),
|
|
687
|
+
titles: [...run.titles],
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** First working weekday after the longest cyclic non-working run; ties go Sunday-first. */
|
|
692
|
+
function weekStartOf(openMask: number): number | undefined {
|
|
693
|
+
const isOff = (weekday: number) => (openMask & (1 << weekday)) !== 0;
|
|
694
|
+
let best: number | undefined;
|
|
695
|
+
let bestLength = 0;
|
|
696
|
+
for (let weekday = 0; weekday < DAYS_PER_WEEK; weekday++) {
|
|
697
|
+
if (isOff(weekday) || !isOff((weekday + 6) % DAYS_PER_WEEK)) continue;
|
|
698
|
+
let length = 0;
|
|
699
|
+
while (length < DAYS_PER_WEEK && isOff((weekday + 6 - length) % DAYS_PER_WEEK)) length++;
|
|
700
|
+
if (length > bestLength) {
|
|
701
|
+
best = weekday;
|
|
702
|
+
bestLength = length;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return best;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
// Queries over the resolved pieces (integer UTC day numbers).
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
function dayNumber(date: Date): number {
|
|
713
|
+
return Math.round(date.getTime() / DAY_MS);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function dateOfDay(day: number): Date {
|
|
717
|
+
return new Date(day * DAY_MS);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function weekdayOf(day: number): number {
|
|
721
|
+
return (((day + EPOCH_WEEKDAY) % DAYS_PER_WEEK) + DAYS_PER_WEEK) % DAYS_PER_WEEK;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function remainderScanError(): Error {
|
|
725
|
+
return new Error('working calendar: remainder scan exceeded its cap (internal error)');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The index of the piece holding `day`. */
|
|
729
|
+
function pieceIndexAt(pieces: ReadonlyArray<Piece>, day: number): number {
|
|
730
|
+
let low = 0;
|
|
731
|
+
let high = pieces.length - 1;
|
|
732
|
+
while (low < high) {
|
|
733
|
+
const middle = (low + high) >> 1;
|
|
734
|
+
if (day >= pieces[middle].end) low = middle + 1;
|
|
735
|
+
else high = middle;
|
|
736
|
+
}
|
|
737
|
+
return low;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function isNonWorkingIn(pieces: ReadonlyArray<Piece>, day: number): boolean {
|
|
741
|
+
const piece = pieces[pieceIndexAt(pieces, day)];
|
|
742
|
+
if (piece.kind === 'mask') return piece.off[weekdayOf(day)];
|
|
743
|
+
return piece.kind === 'non-working';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Working days in [from, to), both finite and inside `piece`. */
|
|
747
|
+
function workingDaysIn(piece: Piece, from: number, to: number): number {
|
|
748
|
+
if (piece.kind !== 'mask') return piece.kind === 'working' ? to - from : 0;
|
|
749
|
+
const weeks = Math.floor((to - from) / DAYS_PER_WEEK);
|
|
750
|
+
let count = weeks * piece.workingPerWeek;
|
|
751
|
+
for (let day = from + weeks * DAYS_PER_WEEK; day < to; day++) {
|
|
752
|
+
if (!piece.off[weekdayOf(day)]) count++;
|
|
753
|
+
}
|
|
754
|
+
return count;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** The working day `rank` (0-based) at or after `from`; it lies inside `piece`. */
|
|
758
|
+
function nthWorkingIn(piece: Piece, from: number, rank: number): number {
|
|
759
|
+
if (piece.kind !== 'mask') return from + rank;
|
|
760
|
+
let day = from + Math.floor(rank / piece.workingPerWeek) * DAYS_PER_WEEK;
|
|
761
|
+
let left = rank % piece.workingPerWeek;
|
|
762
|
+
for (let step = 0; step < MAX_REMAINDER_STEPS; step++, day++) {
|
|
763
|
+
if (piece.off[weekdayOf(day)]) continue;
|
|
764
|
+
if (left === 0) return day;
|
|
765
|
+
left--;
|
|
766
|
+
}
|
|
767
|
+
throw remainderScanError();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The `rank`-th (1-based) working day before `end`; it lies inside `piece`. */
|
|
771
|
+
function nthWorkingBackIn(piece: Piece, end: number, rank: number): number {
|
|
772
|
+
if (piece.kind !== 'mask') return end - rank;
|
|
773
|
+
const weeks = Math.floor((rank - 1) / piece.workingPerWeek);
|
|
774
|
+
let day = end - weeks * DAYS_PER_WEEK;
|
|
775
|
+
let left = rank - weeks * piece.workingPerWeek;
|
|
776
|
+
for (let step = 0; step < MAX_REMAINDER_STEPS; step++) {
|
|
777
|
+
day--;
|
|
778
|
+
if (piece.off[weekdayOf(day)]) continue;
|
|
779
|
+
left--;
|
|
780
|
+
if (left === 0) return day;
|
|
781
|
+
}
|
|
782
|
+
throw remainderScanError();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
class WorkingDayIndex {
|
|
786
|
+
constructor(
|
|
787
|
+
private readonly pieces: ReadonlyArray<Piece>,
|
|
788
|
+
private readonly titled: ReadonlyArray<TitledRule>,
|
|
789
|
+
) {}
|
|
790
|
+
|
|
791
|
+
isWorkingDay(date: Date): boolean {
|
|
792
|
+
const day = dayNumber(date);
|
|
793
|
+
return Number.isNaN(day) || !this.isNonWorking(day);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
indexOf(base: Date, date: Date): number {
|
|
797
|
+
const baseDay = dayNumber(base);
|
|
798
|
+
const dateDay = dayNumber(date);
|
|
799
|
+
if (Number.isNaN(baseDay) || Number.isNaN(dateDay)) return Number.NaN;
|
|
800
|
+
const snapped = this.nthFrom(dateDay, 0);
|
|
801
|
+
return snapped >= baseDay ? this.count(baseDay, snapped) : -this.count(snapped, baseDay);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
dateAt(base: Date, index: number): Date {
|
|
805
|
+
const baseDay = dayNumber(base);
|
|
806
|
+
const rank = Math.floor(index);
|
|
807
|
+
if (Number.isNaN(baseDay) || !Number.isFinite(rank) || Math.abs(rank) > MAX_WORKING_INDEX) {
|
|
808
|
+
return new Date(Number.NaN);
|
|
809
|
+
}
|
|
810
|
+
return dateOfDay(rank >= 0 ? this.nthFrom(baseDay, rank) : this.nthBefore(baseDay, -rank));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
runs(fromDate: Date, toDate: Date): NonWorkingRun[] {
|
|
814
|
+
const from = dayNumber(fromDate);
|
|
815
|
+
const to = dayNumber(toDate);
|
|
816
|
+
if (Number.isNaN(from) || Number.isNaN(to) || from >= to) return [];
|
|
817
|
+
const out: NonWorkingRun[] = [];
|
|
818
|
+
let day = this.isNonWorking(from)
|
|
819
|
+
? this.nthBefore(from, 1) + 1
|
|
820
|
+
: this.nextNonWorking(from, to);
|
|
821
|
+
while (day < to) {
|
|
822
|
+
const through = this.nthFrom(day, 0) - 1;
|
|
823
|
+
out.push({
|
|
824
|
+
from: dateOfDay(day),
|
|
825
|
+
through: dateOfDay(through),
|
|
826
|
+
titles: this.titlesIn(day, through),
|
|
827
|
+
});
|
|
828
|
+
day = this.nextNonWorking(through + 1, to);
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private pieceAt(day: number): number {
|
|
834
|
+
return pieceIndexAt(this.pieces, day);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private isNonWorking(day: number): boolean {
|
|
838
|
+
return isNonWorkingIn(this.pieces, day);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Working days in [from, to), from <= to. */
|
|
842
|
+
private count(from: number, to: number): number {
|
|
843
|
+
let total = 0;
|
|
844
|
+
for (let i = this.pieceAt(from); i < this.pieces.length && this.pieces[i].start < to; i++) {
|
|
845
|
+
const piece = this.pieces[i];
|
|
846
|
+
total += workingDaysIn(piece, Math.max(from, piece.start), Math.min(to, piece.end));
|
|
847
|
+
}
|
|
848
|
+
return total;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The working day `rank` (0-based) at or after `day`. */
|
|
852
|
+
private nthFrom(day: number, rank: number): number {
|
|
853
|
+
let position = day;
|
|
854
|
+
let left = rank;
|
|
855
|
+
for (let i = this.pieceAt(day); i < this.pieces.length; i++) {
|
|
856
|
+
const piece = this.pieces[i];
|
|
857
|
+
if (piece.end === Infinity) return nthWorkingIn(piece, position, left);
|
|
858
|
+
const available = workingDaysIn(piece, position, piece.end);
|
|
859
|
+
if (left < available) return nthWorkingIn(piece, position, left);
|
|
860
|
+
left -= available;
|
|
861
|
+
position = piece.end;
|
|
862
|
+
}
|
|
863
|
+
throw new Error('working calendar: forward walk left the last piece (internal error)');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** The `rank`-th (1-based) working day before `day`. */
|
|
867
|
+
private nthBefore(day: number, rank: number): number {
|
|
868
|
+
let position = day;
|
|
869
|
+
let left = rank;
|
|
870
|
+
for (let i = this.pieceAt(day - 1); i >= 0; i--) {
|
|
871
|
+
const piece = this.pieces[i];
|
|
872
|
+
if (piece.start === -Infinity) return nthWorkingBackIn(piece, position, left);
|
|
873
|
+
const available = workingDaysIn(piece, piece.start, position);
|
|
874
|
+
if (left <= available) return nthWorkingBackIn(piece, position, left);
|
|
875
|
+
left -= available;
|
|
876
|
+
position = piece.start;
|
|
877
|
+
}
|
|
878
|
+
throw new Error('working calendar: backward walk left the first piece (internal error)');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** First non-working day in [day, limit), or `limit`. */
|
|
882
|
+
private nextNonWorking(day: number, limit: number): number {
|
|
883
|
+
for (
|
|
884
|
+
let i = this.pieceAt(day);
|
|
885
|
+
i < this.pieces.length && this.pieces[i].start < limit;
|
|
886
|
+
i++
|
|
887
|
+
) {
|
|
888
|
+
const piece = this.pieces[i];
|
|
889
|
+
const from = Math.max(day, piece.start);
|
|
890
|
+
if (piece.kind === 'non-working') return from;
|
|
891
|
+
if (piece.kind !== 'mask' || piece.workingPerWeek === DAYS_PER_WEEK) continue;
|
|
892
|
+
for (let offset = 0; offset < DAYS_PER_WEEK && from + offset < piece.end; offset++) {
|
|
893
|
+
if (piece.off[weekdayOf(from + offset)]) return from + offset;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return limit;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Titles of the titled dated non-working rules with a day in [from, through]. */
|
|
900
|
+
private titlesIn(from: number, through: number): string[] {
|
|
901
|
+
const titles: string[] = [];
|
|
902
|
+
for (const rule of this.titled) {
|
|
903
|
+
if (rule.spans.some((span) => span.start <= through && span.end > from)) {
|
|
904
|
+
titles.push(rule.title);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return titles;
|
|
908
|
+
}
|
|
909
|
+
}
|