@dou-ui/gantt 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/collaboration/index.js +323 -0
- package/dist/collaboration/index.js.map +1 -0
- package/dist/collaboration/peers.js +78 -0
- package/dist/collaboration/peers.js.map +1 -0
- package/dist/components/cell-editor.js +6 -0
- package/dist/components/cell-editor.js.map +1 -0
- package/dist/components/cell-editor.vue_vue_type_script_setup_true_lang.js +114 -0
- package/dist/components/cell-editor.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/gantt-menu.js +6 -0
- package/dist/components/gantt-menu.js.map +1 -0
- package/dist/components/gantt-menu.vue_vue_type_script_setup_true_lang.js +40 -0
- package/dist/components/gantt-menu.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/time-grid.js +6 -0
- package/dist/components/time-grid.js.map +1 -0
- package/dist/components/time-grid.vue_vue_type_script_setup_true_lang.js +32 -0
- package/dist/components/time-grid.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/time-header.js +6 -0
- package/dist/components/time-header.js.map +1 -0
- package/dist/components/time-header.vue_vue_type_script_setup_true_lang.js +60 -0
- package/dist/components/time-header.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/context.js +12 -0
- package/dist/context.js.map +1 -0
- package/dist/core/baseline.js +48 -0
- package/dist/core/baseline.js.map +1 -0
- package/dist/core/calendar.js +191 -0
- package/dist/core/calendar.js.map +1 -0
- package/dist/core/csv.js +424 -0
- package/dist/core/csv.js.map +1 -0
- package/dist/core/document.js +231 -0
- package/dist/core/document.js.map +1 -0
- package/dist/core/extension.js +42 -0
- package/dist/core/extension.js.map +1 -0
- package/dist/core/geometry.js +112 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/history.js +70 -0
- package/dist/core/history.js.map +1 -0
- package/dist/core/links.js +72 -0
- package/dist/core/links.js.map +1 -0
- package/dist/core/model.js +52 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/order.js +34 -0
- package/dist/core/order.js.map +1 -0
- package/dist/core/scale.js +118 -0
- package/dist/core/scale.js.map +1 -0
- package/dist/core/schedule.js +235 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/time.js +81 -0
- package/dist/core/time.js.map +1 -0
- package/dist/core/transaction.js +371 -0
- package/dist/core/transaction.js.map +1 -0
- package/dist/core/tree.js +114 -0
- package/dist/core/tree.js.map +1 -0
- package/dist/export/draw.js +214 -0
- package/dist/export/draw.js.map +1 -0
- package/dist/export/theme.js +131 -0
- package/dist/export/theme.js.map +1 -0
- package/dist/gantt.js +6 -0
- package/dist/gantt.js.map +1 -0
- package/dist/gantt.vue_vue_type_script_setup_true_lang.js +2774 -0
- package/dist/gantt.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.js +87 -0
- package/dist/labels.js.map +1 -0
- package/dist/locale.js +355 -0
- package/dist/locale.js.map +1 -0
- package/dist/scheduler/index.js +8 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/layout.js +97 -0
- package/dist/scheduler/layout.js.map +1 -0
- package/dist/scheduler/scheduler.js +6 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/scheduler.vue_vue_type_script_setup_true_lang.js +1359 -0
- package/dist/scheduler/scheduler.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/scheduler/style.d.ts +1 -0
- package/dist/scheduler/style.js +2 -0
- package/dist/style.css +1 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +4 -0
- package/dist/time-axis.js +337 -0
- package/dist/time-axis.js.map +1 -0
- package/dist/types/collaboration/index.d.ts +39 -0
- package/dist/types/collaboration/peers.d.ts +22 -0
- package/dist/types/components/cell-editor.vue.d.ts +21 -0
- package/dist/types/components/gantt-menu.vue.d.ts +18 -0
- package/dist/types/components/time-grid.vue.d.ts +12 -0
- package/dist/types/components/time-header.vue.d.ts +23 -0
- package/dist/types/context.d.ts +64 -0
- package/dist/types/core/baseline.d.ts +14 -0
- package/dist/types/core/calendar.d.ts +75 -0
- package/dist/types/core/csv.d.ts +77 -0
- package/dist/types/core/document.d.ts +51 -0
- package/dist/types/core/extension.d.ts +88 -0
- package/dist/types/core/geometry.d.ts +33 -0
- package/dist/types/core/history.d.ts +19 -0
- package/dist/types/core/links.d.ts +20 -0
- package/dist/types/core/menu.d.ts +27 -0
- package/dist/types/core/model.d.ts +72 -0
- package/dist/types/core/order.d.ts +9 -0
- package/dist/types/core/scale.d.ts +33 -0
- package/dist/types/core/schedule.d.ts +44 -0
- package/dist/types/core/time.d.ts +39 -0
- package/dist/types/core/transaction.d.ts +134 -0
- package/dist/types/core/tree.d.ts +30 -0
- package/dist/types/export/draw.d.ts +76 -0
- package/dist/types/export/theme.d.ts +49 -0
- package/dist/types/gantt.vue.d.ts +54 -0
- package/dist/types/index.d.ts +148 -0
- package/dist/types/labels.d.ts +24 -0
- package/dist/types/locale.d.ts +161 -0
- package/dist/types/scheduler/index.d.ts +102 -0
- package/dist/types/scheduler/layout.d.ts +58 -0
- package/dist/types/scheduler/scheduler.vue.d.ts +46 -0
- package/dist/types/scheduler/types.d.ts +109 -0
- package/dist/types/time-axis.d.ts +80 -0
- package/dist/types/types.d.ts +305 -0
- package/package.json +79 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { MINUTES_PER_DAY, formatTime, isMidnight, parseTime } from "./time.js";
|
|
2
|
+
// src/core/model.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reads a task's dates. A date-only `end` covers its whole day; a missing or
|
|
5
|
+
* early one makes the task one day (or one hour) long. A milestone's span
|
|
6
|
+
* is empty: `start` and `end` are the same moment, the middle of its day
|
|
7
|
+
* when it has no time.
|
|
8
|
+
*/
|
|
9
|
+
function spanOf(task) {
|
|
10
|
+
const start = parseTime(task.start);
|
|
11
|
+
if (!start) return null;
|
|
12
|
+
if (task.type === "milestone") {
|
|
13
|
+
const at = start.dateOnly ? start.at + MINUTES_PER_DAY / 2 : start.at;
|
|
14
|
+
return {
|
|
15
|
+
start: at,
|
|
16
|
+
end: at,
|
|
17
|
+
dateOnly: start.dateOnly
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const end = parseTime(task.end);
|
|
21
|
+
const dateOnly = start.dateOnly && (!end || end.dateOnly);
|
|
22
|
+
const unit = dateOnly ? MINUTES_PER_DAY : 60;
|
|
23
|
+
let to = end ? end.dateOnly ? end.at + MINUTES_PER_DAY : end.at : start.at + unit;
|
|
24
|
+
if (to <= start.at) to = start.at + unit;
|
|
25
|
+
return {
|
|
26
|
+
start: start.at,
|
|
27
|
+
end: to,
|
|
28
|
+
dateOnly
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The `start` and `end` strings for a span, in the task's own style: whole
|
|
33
|
+
* days stay date-only, anything else is written with times.
|
|
34
|
+
*/
|
|
35
|
+
function writeSpan(task, start, end) {
|
|
36
|
+
const was = spanOf(task);
|
|
37
|
+
const wholeDays = isMidnight(start) && isMidnight(end) && end > start;
|
|
38
|
+
if (task.type === "milestone") return { start: (was?.dateOnly ?? true) && isMidnight(start - 720) ? formatTime(start - MINUTES_PER_DAY / 2, true) : formatTime(start) };
|
|
39
|
+
if ((was?.dateOnly ?? true) && wholeDays) return {
|
|
40
|
+
start: formatTime(start, true),
|
|
41
|
+
end: formatTime(end - MINUTES_PER_DAY, true)
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
start: formatTime(start),
|
|
45
|
+
end: formatTime(end)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
var clampProgress = (value) => Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
|
|
49
|
+
var createId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
50
|
+
export { clampProgress, createId, spanOf, writeSpan };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.js","names":[],"sources":["../../src/core/model.ts"],"sourcesContent":["import type { Tone } from 'dou-ui'\nimport { MINUTES_PER_DAY, MINUTES_PER_HOUR, formatTime, isMidnight, parseTime } from './time'\n\nexport type GanttData = Record<string, unknown>\n\nexport interface GanttTask<Data extends GanttData = GanttData> {\n id: string\n title: string\n /** The parent task's id. A task with children is a summary task. */\n parent?: string\n /**\n * Sort key among siblings, compared as a string. Siblings without one\n * keep their order in the array.\n */\n order?: string\n /** A milestone is a point in time: it only reads `start`. */\n type?: 'task' | 'milestone'\n /** `YYYY-MM-DD` or `YYYY-MM-DD HH:mm`. Without it the task is not scheduled yet. */\n start?: string\n /**\n * `YYYY-MM-DD` for the last day of the task, or `YYYY-MM-DD HH:mm` for the\n * moment it ends. Defaults to one day, or one hour after a timed `start`.\n */\n end?: string\n /** From `0` to `1`. */\n progress?: number\n tone?: Tone\n /** Hide the children. */\n collapsed?: boolean\n /** Can't be moved or resized. */\n locked?: boolean\n /** The id of the resource it is assigned to, for `DouScheduler`. */\n resource?: string\n /** Your own fields, e.g. for columns. */\n data?: Data\n}\n\nexport type GanttLinkType = 'fs' | 'ss' | 'ff' | 'sf'\n\n/** A dependency: `to` waits for `from`. */\nexport interface GanttLink {\n id: string\n from: string\n to: string\n /**\n * `fs` finish to start (the default): `to` starts after `from` finishes;\n * `ss` start to start; `ff` finish to finish; `sf` start to finish.\n */\n type?: GanttLinkType\n /**\n * Working time between the two ends; may be negative. A number counts\n * working days; a string gives a unit: `'2d'` working days, `'4h'`\n * working hours, `'30m'` working minutes.\n */\n lag?: number | string\n}\n\n/** Where a task sits in time, in minutes since the epoch. `end` is exclusive. */\nexport interface TaskSpan {\n start: number\n end: number\n /** Both ends are whole days, written without a time. */\n dateOnly: boolean\n}\n\n/**\n * Reads a task's dates. A date-only `end` covers its whole day; a missing or\n * early one makes the task one day (or one hour) long. A milestone's span\n * is empty: `start` and `end` are the same moment, the middle of its day\n * when it has no time.\n */\nexport function spanOf(task: GanttTask): TaskSpan | null {\n const start = parseTime(task.start)\n if (!start) return null\n if (task.type === 'milestone') {\n const at = start.dateOnly ? start.at + MINUTES_PER_DAY / 2 : start.at\n return { start: at, end: at, dateOnly: start.dateOnly }\n }\n const end = parseTime(task.end)\n const dateOnly = start.dateOnly && (!end || end.dateOnly)\n const unit = dateOnly ? MINUTES_PER_DAY : MINUTES_PER_HOUR\n let to = end ? (end.dateOnly ? end.at + MINUTES_PER_DAY : end.at) : start.at + unit\n if (to <= start.at) to = start.at + unit\n return { start: start.at, end: to, dateOnly }\n}\n\n/**\n * The `start` and `end` strings for a span, in the task's own style: whole\n * days stay date-only, anything else is written with times.\n */\nexport function writeSpan(\n task: GanttTask,\n start: number,\n end: number,\n): Pick<GanttTask, 'start' | 'end'> {\n const was = spanOf(task)\n const wholeDays = isMidnight(start) && isMidnight(end) && end > start\n if (task.type === 'milestone') {\n const dateOnly = (was?.dateOnly ?? true) && isMidnight(start - MINUTES_PER_DAY / 2)\n return {\n start: dateOnly ? formatTime(start - MINUTES_PER_DAY / 2, true) : formatTime(start),\n }\n }\n if ((was?.dateOnly ?? true) && wholeDays)\n return { start: formatTime(start, true), end: formatTime(end - MINUTES_PER_DAY, true) }\n return { start: formatTime(start), end: formatTime(end) }\n}\n\nexport const clampProgress = (value: number | undefined) =>\n Math.min(1, Math.max(0, Number.isFinite(value) ? value! : 0))\n\nexport const createId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`\n"],"mappings":";;;;;;;;AAuEA,SAAgB,OAAO,MAAkC;CACvD,MAAM,QAAQ,UAAU,KAAK,KAAK;CAClC,IAAI,CAAC,OAAO,OAAO;CACnB,IAAI,KAAK,SAAS,aAAa;EAC7B,MAAM,KAAK,MAAM,WAAW,MAAM,KAAK,kBAAkB,IAAI,MAAM;EACnE,OAAO;GAAE,OAAO;GAAI,KAAK;GAAI,UAAU,MAAM;EAAS;CACxD;CACA,MAAM,MAAM,UAAU,KAAK,GAAG;CAC9B,MAAM,WAAW,MAAM,aAAa,CAAC,OAAO,IAAI;CAChD,MAAM,OAAO,WAAW,kBAAA;CACxB,IAAI,KAAK,MAAO,IAAI,WAAW,IAAI,KAAK,kBAAkB,IAAI,KAAM,MAAM,KAAK;CAC/E,IAAI,MAAM,MAAM,IAAI,KAAK,MAAM,KAAK;CACpC,OAAO;EAAE,OAAO,MAAM;EAAI,KAAK;EAAI;CAAS;AAC9C;;;;;AAMA,SAAgB,UACd,MACA,OACA,KACkC;CAClC,MAAM,MAAM,OAAO,IAAI;CACvB,MAAM,YAAY,WAAW,KAAK,KAAK,WAAW,GAAG,KAAK,MAAM;CAChE,IAAI,KAAK,SAAS,aAEhB,OAAO,EACL,QAFgB,KAAK,YAAY,SAAS,WAAW,QAAA,GAA2B,IAE9D,WAAW,QAAQ,kBAAkB,GAAG,IAAI,IAAI,WAAW,KAAK,EACpF;CAEF,KAAK,KAAK,YAAY,SAAS,WAC7B,OAAO;EAAE,OAAO,WAAW,OAAO,IAAI;EAAG,KAAK,WAAW,MAAM,iBAAiB,IAAI;CAAE;CACxF,OAAO;EAAE,OAAO,WAAW,KAAK;EAAG,KAAK,WAAW,GAAG;CAAE;AAC1D;AAEA,IAAa,iBAAiB,UAC5B,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,OAAO,SAAS,KAAK,IAAI,QAAS,CAAC,CAAC;AAE9D,IAAa,iBAAiB,GAAG,KAAK,IAAI,CAAC,CAAC,SAAS,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/core/order.ts
|
|
2
|
+
var DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3
|
+
var BASE = 36;
|
|
4
|
+
/** Whether a key is made of the characters `orderBetween` uses. */
|
|
5
|
+
var isOrderKey = (key) => /^[0-9a-z]*[1-9a-z]$/.test(key);
|
|
6
|
+
/**
|
|
7
|
+
* A key that sorts after `before` and before `after`; leave either out for
|
|
8
|
+
* the start or the end. `before` must sort before `after`.
|
|
9
|
+
*/
|
|
10
|
+
function orderBetween(before = "", after) {
|
|
11
|
+
let out = "";
|
|
12
|
+
let bound = after;
|
|
13
|
+
for (let i = 0;; i++) {
|
|
14
|
+
const low = i < before.length ? DIGITS.indexOf(before[i]) : 0;
|
|
15
|
+
const high = bound != null ? i < bound.length ? DIGITS.indexOf(bound[i]) : 0 : BASE;
|
|
16
|
+
if (high - low > 1) return out + DIGITS[Math.ceil((low + high) / 2)];
|
|
17
|
+
out += DIGITS[low];
|
|
18
|
+
if (high > low) bound = void 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** `count` keys in order, spread out so that later insertions stay short. */
|
|
22
|
+
function orderKeys(count) {
|
|
23
|
+
let width = 1;
|
|
24
|
+
while (BASE ** width <= count + 1) width++;
|
|
25
|
+
const step = Math.floor(BASE ** width / (count + 1));
|
|
26
|
+
return Array.from({ length: count }, (_, i) => {
|
|
27
|
+
let key = ((i + 1) * step).toString(36).padStart(width, "0");
|
|
28
|
+
while (key.endsWith("0")) key = key.slice(0, -1);
|
|
29
|
+
return key;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export { isOrderKey, orderBetween, orderKeys };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=order.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"order.js","names":[],"sources":["../../src/core/order.ts"],"sourcesContent":["/*\n * Sibling order keys are strings compared character by character. A key\n * between two others can always be made by going one character longer,\n * so moving a task rewrites only that task, which suits collaboration.\n */\n\nconst DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'\nconst BASE = DIGITS.length\n\n/** Whether a key is made of the characters `orderBetween` uses. */\nexport const isOrderKey = (key: string) => /^[0-9a-z]*[1-9a-z]$/.test(key)\n\n/**\n * A key that sorts after `before` and before `after`; leave either out for\n * the start or the end. `before` must sort before `after`.\n */\nexport function orderBetween(before = '', after?: string) {\n let out = ''\n // once the key is below `after` at some character, `after` stops bounding it\n let bound = after\n for (let i = 0; ; i++) {\n const low = i < before.length ? DIGITS.indexOf(before[i]!) : 0\n const high = bound != null ? (i < bound.length ? DIGITS.indexOf(bound[i]!) : 0) : BASE\n if (high - low > 1) return out + DIGITS[Math.ceil((low + high) / 2)]\n out += DIGITS[low]\n if (high > low) bound = undefined\n }\n}\n\n/** `count` keys in order, spread out so that later insertions stay short. */\nexport function orderKeys(count: number) {\n let width = 1\n while (BASE ** width <= count + 1) width++\n const step = Math.floor(BASE ** width / (count + 1))\n return Array.from({ length: count }, (_, i) => {\n let key = ((i + 1) * step).toString(36).padStart(width, '0')\n // a key never ends in 0, so one can always be made before it\n while (key.endsWith('0')) key = key.slice(0, -1)\n return key\n })\n}\n"],"mappings":";AAMA,IAAM,SAAS;AACf,IAAM,OAAO;;AAGb,IAAa,cAAc,QAAgB,sBAAsB,KAAK,GAAG;;;;;AAMzE,SAAgB,aAAa,SAAS,IAAI,OAAgB;CACxD,IAAI,MAAM;CAEV,IAAI,QAAQ;CACZ,KAAK,IAAI,IAAI,IAAK,KAAK;EACrB,MAAM,MAAM,IAAI,OAAO,SAAS,OAAO,QAAQ,OAAO,EAAG,IAAI;EAC7D,MAAM,OAAO,SAAS,OAAQ,IAAI,MAAM,SAAS,OAAO,QAAQ,MAAM,EAAG,IAAI,IAAK;EAClF,IAAI,OAAO,MAAM,GAAG,OAAO,MAAM,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;EAClE,OAAO,OAAO;EACd,IAAI,OAAO,KAAK,QAAQ,KAAA;CAC1B;AACF;;AAGA,SAAgB,UAAU,OAAe;CACvC,IAAI,QAAQ;CACZ,OAAO,QAAQ,SAAS,QAAQ,GAAG;CACnC,MAAM,OAAO,KAAK,MAAM,QAAQ,SAAS,QAAQ,EAAE;CACnD,OAAO,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,MAAM;EAC7C,IAAI,QAAQ,IAAI,KAAK,KAAA,CAAM,SAAS,EAAE,CAAC,CAAC,SAAS,OAAO,GAAG;EAE3D,OAAO,IAAI,SAAS,GAAG,GAAG,MAAM,IAAI,MAAM,GAAG,EAAE;EAC/C,OAAO;CACT,CAAC;AACH"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MINUTES_PER_DAY, dayStart, fromParts, partsOf } from "./time.js";
|
|
2
|
+
// src/core/scale.ts
|
|
3
|
+
/** From the widest days to the narrowest. */
|
|
4
|
+
var scaleLevels = [
|
|
5
|
+
{
|
|
6
|
+
name: "hour",
|
|
7
|
+
min: 288,
|
|
8
|
+
base: 1152,
|
|
9
|
+
top: "day",
|
|
10
|
+
bottom: "hour"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "day",
|
|
14
|
+
min: 24,
|
|
15
|
+
base: 40,
|
|
16
|
+
top: "month",
|
|
17
|
+
bottom: "day"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "week",
|
|
21
|
+
min: 8,
|
|
22
|
+
base: 16,
|
|
23
|
+
top: "month",
|
|
24
|
+
bottom: "week"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "month",
|
|
28
|
+
min: 2,
|
|
29
|
+
base: 4,
|
|
30
|
+
top: "year",
|
|
31
|
+
bottom: "month"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "quarter",
|
|
35
|
+
min: .6,
|
|
36
|
+
base: 1.2,
|
|
37
|
+
top: "year",
|
|
38
|
+
bottom: "quarter"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "year",
|
|
42
|
+
min: 0,
|
|
43
|
+
base: .32,
|
|
44
|
+
top: "year",
|
|
45
|
+
bottom: "half"
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
/** The widest and narrowest a day may be, in px. */
|
|
49
|
+
var DAY_WIDTH_RANGE = [.16, 2880];
|
|
50
|
+
var levelByName = (name) => scaleLevels.find((level) => level.name === name) ?? scaleLevels[1];
|
|
51
|
+
/** The level that shows days `dayWidth` px wide. */
|
|
52
|
+
var levelOf = (dayWidth) => scaleLevels.find((level) => dayWidth >= level.min) ?? scaleLevels[scaleLevels.length - 1];
|
|
53
|
+
/** Hours per header cell, so that labels have room. */
|
|
54
|
+
function hourStep(dayWidth) {
|
|
55
|
+
const hour = dayWidth / 24;
|
|
56
|
+
return [
|
|
57
|
+
1,
|
|
58
|
+
2,
|
|
59
|
+
3,
|
|
60
|
+
4,
|
|
61
|
+
6,
|
|
62
|
+
12
|
|
63
|
+
].find((step) => step * hour >= 48) ?? 12;
|
|
64
|
+
}
|
|
65
|
+
/** The step that drags and new dates snap to, in minutes. */
|
|
66
|
+
function snapStep(dayWidth) {
|
|
67
|
+
if (dayWidth < scaleLevels[0].min) return MINUTES_PER_DAY;
|
|
68
|
+
const hour = dayWidth / 24;
|
|
69
|
+
return hour >= 80 ? 15 : hour >= 40 ? 30 : 60;
|
|
70
|
+
}
|
|
71
|
+
/** The start of the `unit` that holds `at`. */
|
|
72
|
+
function unitStart(unit, at, weekStart = 1, step = 1) {
|
|
73
|
+
switch (unit) {
|
|
74
|
+
case "hour": {
|
|
75
|
+
const hours = Math.floor(at / 60);
|
|
76
|
+
return (hours - (hours % 24 % step + step) % step) * 60;
|
|
77
|
+
}
|
|
78
|
+
case "day": return dayStart(at);
|
|
79
|
+
case "week": {
|
|
80
|
+
const { weekday } = partsOf(at);
|
|
81
|
+
return dayStart(at) - (weekday - weekStart + 7) % 7 * MINUTES_PER_DAY;
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
const { year, month } = partsOf(at);
|
|
85
|
+
if (unit === "year") return fromParts(year, 1, 1);
|
|
86
|
+
const months = unit === "month" ? 1 : unit === "quarter" ? 3 : 6;
|
|
87
|
+
return fromParts(year, month - (month - 1) % months, 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The start of the `unit` after the one that starts at `at`. */
|
|
92
|
+
function nextUnit(unit, at, step = 1) {
|
|
93
|
+
switch (unit) {
|
|
94
|
+
case "hour": return at + step * 60;
|
|
95
|
+
case "day": return at + MINUTES_PER_DAY;
|
|
96
|
+
case "week": return at + 7 * MINUTES_PER_DAY;
|
|
97
|
+
default: {
|
|
98
|
+
const { year, month } = partsOf(at);
|
|
99
|
+
return fromParts(year, month + (unit === "month" ? 1 : unit === "quarter" ? 3 : unit === "half" ? 6 : 12), 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The units that overlap `[from, to)`, as their start and end. */
|
|
104
|
+
function unitsBetween(unit, from, to, weekStart = 1, step = 1) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let at = unitStart(unit, from, weekStart, step); at < to;) {
|
|
107
|
+
const next = nextUnit(unit, at, step);
|
|
108
|
+
out.push({
|
|
109
|
+
start: at,
|
|
110
|
+
end: next
|
|
111
|
+
});
|
|
112
|
+
at = next;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
export { DAY_WIDTH_RANGE, hourStep, levelByName, levelOf, nextUnit, scaleLevels, snapStep, unitStart, unitsBetween };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=scale.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scale.js","names":[],"sources":["../../src/core/scale.ts"],"sourcesContent":["import { MINUTES_PER_DAY, MINUTES_PER_HOUR, dayStart, fromParts, partsOf } from './time'\n\nexport type GanttScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'\n\n/** A step of the time header. */\nexport type TimeUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'half' | 'year'\n\nexport interface ScaleLevel {\n name: GanttScale\n /** The narrowest a day gets at this level, in px; below it the next level takes over. */\n min: number\n /** How wide a day is when the level is picked, in px. */\n base: number\n /** The two rows of the header. */\n top: TimeUnit\n bottom: TimeUnit\n}\n\n/** From the widest days to the narrowest. */\nexport const scaleLevels: readonly ScaleLevel[] = [\n { name: 'hour', min: 288, base: 1152, top: 'day', bottom: 'hour' },\n { name: 'day', min: 24, base: 40, top: 'month', bottom: 'day' },\n { name: 'week', min: 8, base: 16, top: 'month', bottom: 'week' },\n { name: 'month', min: 2, base: 4, top: 'year', bottom: 'month' },\n { name: 'quarter', min: 0.6, base: 1.2, top: 'year', bottom: 'quarter' },\n { name: 'year', min: 0, base: 0.32, top: 'year', bottom: 'half' },\n]\n\n/** The widest and narrowest a day may be, in px. */\nexport const DAY_WIDTH_RANGE: [number, number] = [0.16, 2880]\n\nexport const levelByName = (name: GanttScale) =>\n scaleLevels.find((level) => level.name === name) ?? scaleLevels[1]!\n\n/** The level that shows days `dayWidth` px wide. */\nexport const levelOf = (dayWidth: number) =>\n scaleLevels.find((level) => dayWidth >= level.min) ?? scaleLevels[scaleLevels.length - 1]!\n\n/** Hours per header cell, so that labels have room. */\nexport function hourStep(dayWidth: number) {\n const hour = dayWidth / 24\n return [1, 2, 3, 4, 6, 12].find((step) => step * hour >= 48) ?? 12\n}\n\n/** The step that drags and new dates snap to, in minutes. */\nexport function snapStep(dayWidth: number) {\n if (dayWidth < scaleLevels[0]!.min) return MINUTES_PER_DAY\n const hour = dayWidth / 24\n return hour >= 80 ? 15 : hour >= 40 ? 30 : MINUTES_PER_HOUR\n}\n\n/** The start of the `unit` that holds `at`. */\nexport function unitStart(unit: TimeUnit, at: number, weekStart: 0 | 1 = 1, step = 1) {\n switch (unit) {\n case 'hour': {\n const hours = Math.floor(at / MINUTES_PER_HOUR)\n return (hours - ((((hours % 24) % step) + step) % step)) * MINUTES_PER_HOUR\n }\n case 'day':\n return dayStart(at)\n case 'week': {\n const { weekday } = partsOf(at)\n return dayStart(at) - ((weekday - weekStart + 7) % 7) * MINUTES_PER_DAY\n }\n default: {\n const { year, month } = partsOf(at)\n if (unit === 'year') return fromParts(year, 1, 1)\n const months = unit === 'month' ? 1 : unit === 'quarter' ? 3 : 6\n return fromParts(year, month - ((month - 1) % months), 1)\n }\n }\n}\n\n/** The start of the `unit` after the one that starts at `at`. */\nexport function nextUnit(unit: TimeUnit, at: number, step = 1) {\n switch (unit) {\n case 'hour':\n return at + step * MINUTES_PER_HOUR\n case 'day':\n return at + MINUTES_PER_DAY\n case 'week':\n return at + 7 * MINUTES_PER_DAY\n default: {\n const { year, month } = partsOf(at)\n const months = unit === 'month' ? 1 : unit === 'quarter' ? 3 : unit === 'half' ? 6 : 12\n return fromParts(year, month + months, 1)\n }\n }\n}\n\n/** The units that overlap `[from, to)`, as their start and end. */\nexport function unitsBetween(\n unit: TimeUnit,\n from: number,\n to: number,\n weekStart: 0 | 1 = 1,\n step = 1,\n) {\n const out: { start: number; end: number }[] = []\n for (let at = unitStart(unit, from, weekStart, step); at < to;) {\n const next = nextUnit(unit, at, step)\n out.push({ start: at, end: next })\n at = next\n }\n return out\n}\n"],"mappings":";;;AAmBA,IAAa,cAAqC;CAChD;EAAE,MAAM;EAAQ,KAAK;EAAK,MAAM;EAAM,KAAK;EAAO,QAAQ;CAAO;CACjE;EAAE,MAAM;EAAO,KAAK;EAAI,MAAM;EAAI,KAAK;EAAS,QAAQ;CAAM;CAC9D;EAAE,MAAM;EAAQ,KAAK;EAAG,MAAM;EAAI,KAAK;EAAS,QAAQ;CAAO;CAC/D;EAAE,MAAM;EAAS,KAAK;EAAG,MAAM;EAAG,KAAK;EAAQ,QAAQ;CAAQ;CAC/D;EAAE,MAAM;EAAW,KAAK;EAAK,MAAM;EAAK,KAAK;EAAQ,QAAQ;CAAU;CACvE;EAAE,MAAM;EAAQ,KAAK;EAAG,MAAM;EAAM,KAAK;EAAQ,QAAQ;CAAO;AAClE;;AAGA,IAAa,kBAAoC,CAAC,KAAM,IAAI;AAE5D,IAAa,eAAe,SAC1B,YAAY,MAAM,UAAU,MAAM,SAAS,IAAI,KAAK,YAAY;;AAGlE,IAAa,WAAW,aACtB,YAAY,MAAM,UAAU,YAAY,MAAM,GAAG,KAAK,YAAY,YAAY,SAAS;;AAGzF,SAAgB,SAAS,UAAkB;CACzC,MAAM,OAAO,WAAW;CACxB,OAAO;EAAC;EAAG;EAAG;EAAG;EAAG;EAAG;CAAE,CAAC,CAAC,MAAM,SAAS,OAAO,QAAQ,EAAE,KAAK;AAClE;;AAGA,SAAgB,SAAS,UAAkB;CACzC,IAAI,WAAW,YAAY,EAAE,CAAE,KAAK,OAAO;CAC3C,MAAM,OAAO,WAAW;CACxB,OAAO,QAAQ,KAAK,KAAK,QAAQ,KAAK,KAAA;AACxC;;AAGA,SAAgB,UAAU,MAAgB,IAAY,YAAmB,GAAG,OAAO,GAAG;CACpF,QAAQ,MAAR;EACE,KAAK,QAAQ;GACX,MAAM,QAAQ,KAAK,MAAM,KAAA,EAAqB;GAC9C,QAAQ,SAAY,QAAQ,KAAM,OAAQ,QAAQ,QAAA;EACpD;EACA,KAAK,OACH,OAAO,SAAS,EAAE;EACpB,KAAK,QAAQ;GACX,MAAM,EAAE,YAAY,QAAQ,EAAE;GAC9B,OAAO,SAAS,EAAE,KAAM,UAAU,YAAY,KAAK,IAAK;EAC1D;EACA,SAAS;GACP,MAAM,EAAE,MAAM,UAAU,QAAQ,EAAE;GAClC,IAAI,SAAS,QAAQ,OAAO,UAAU,MAAM,GAAG,CAAC;GAChD,MAAM,SAAS,SAAS,UAAU,IAAI,SAAS,YAAY,IAAI;GAC/D,OAAO,UAAU,MAAM,SAAU,QAAQ,KAAK,QAAS,CAAC;EAC1D;CACF;AACF;;AAGA,SAAgB,SAAS,MAAgB,IAAY,OAAO,GAAG;CAC7D,QAAQ,MAAR;EACE,KAAK,QACH,OAAO,KAAK,OAAA;EACd,KAAK,OACH,OAAO,KAAK;EACd,KAAK,QACH,OAAO,KAAK,IAAI;EAClB,SAAS;GACP,MAAM,EAAE,MAAM,UAAU,QAAQ,EAAE;GAElC,OAAO,UAAU,MAAM,SADR,SAAS,UAAU,IAAI,SAAS,YAAY,IAAI,SAAS,SAAS,IAAI,KAC9C,CAAC;EAC1C;CACF;AACF;;AAGA,SAAgB,aACd,MACA,MACA,IACA,YAAmB,GACnB,OAAO,GACP;CACA,MAAM,MAAwC,CAAC;CAC/C,KAAK,IAAI,KAAK,UAAU,MAAM,MAAM,WAAW,IAAI,GAAG,KAAK,KAAK;EAC9D,MAAM,OAAO,SAAS,MAAM,IAAI,IAAI;EACpC,IAAI,KAAK;GAAE,OAAO;GAAI,KAAK;EAAK,CAAC;EACjC,KAAK;CACP;CACA,OAAO;AACT"}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { MINUTES_PER_DAY } from "./time.js";
|
|
2
|
+
import { endKeepingWork, nextWorkTime, resolveCalendar, shiftWorkMinutes, shiftWorkdays, startKeepingWork, workIn } from "./calendar.js";
|
|
3
|
+
import { writeSpan } from "./model.js";
|
|
4
|
+
import { buildTree } from "./tree.js";
|
|
5
|
+
import { parseLag } from "./links.js";
|
|
6
|
+
// src/core/schedule.ts
|
|
7
|
+
var toCalendar = (calendar) => calendar && "isWorkday" in calendar ? calendar : resolveCalendar(calendar);
|
|
8
|
+
function push(map, id, link) {
|
|
9
|
+
const list = map.get(id);
|
|
10
|
+
if (list) list.push(link);
|
|
11
|
+
else map.set(id, [link]);
|
|
12
|
+
}
|
|
13
|
+
function buildGraph(doc, calendar) {
|
|
14
|
+
const tree = buildTree(doc.tasks);
|
|
15
|
+
const items = /* @__PURE__ */ new Map();
|
|
16
|
+
for (const row of tree.byId.values()) {
|
|
17
|
+
if (row.summary || !row.span) continue;
|
|
18
|
+
const { start, end, dateOnly } = row.span;
|
|
19
|
+
const at = row.milestone && dateOnly ? start + MINUTES_PER_DAY / 2 : start;
|
|
20
|
+
items.set(row.task.id, {
|
|
21
|
+
task: row.task,
|
|
22
|
+
start: at,
|
|
23
|
+
end: row.milestone ? at : end,
|
|
24
|
+
dateOnly,
|
|
25
|
+
milestone: row.milestone
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const leafCache = /* @__PURE__ */ new Map();
|
|
29
|
+
const leavesOf = (id) => {
|
|
30
|
+
const cached = leafCache.get(id);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
const item = items.get(id);
|
|
33
|
+
const out = item ? [item] : [];
|
|
34
|
+
if (!item) for (const child of tree.childrenOf.get(id) ?? []) out.push(...leavesOf(child.id));
|
|
35
|
+
leafCache.set(id, out);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
39
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
40
|
+
const next = /* @__PURE__ */ new Map();
|
|
41
|
+
const degree = /* @__PURE__ */ new Map();
|
|
42
|
+
for (const id of items.keys()) degree.set(id, 0);
|
|
43
|
+
for (const link of doc.links) {
|
|
44
|
+
const from = leavesOf(link.from);
|
|
45
|
+
const to = leavesOf(link.to);
|
|
46
|
+
if (!from.length || !to.length || from.some((item) => to.includes(item))) continue;
|
|
47
|
+
for (const item of to) push(incoming, item.task.id, link);
|
|
48
|
+
for (const item of from) push(outgoing, item.task.id, link);
|
|
49
|
+
for (const a of from) {
|
|
50
|
+
let set = next.get(a.task.id);
|
|
51
|
+
if (!set) next.set(a.task.id, set = /* @__PURE__ */ new Set());
|
|
52
|
+
for (const b of to) {
|
|
53
|
+
if (set.has(b.task.id)) continue;
|
|
54
|
+
set.add(b.task.id);
|
|
55
|
+
degree.set(b.task.id, degree.get(b.task.id) + 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const order = [];
|
|
60
|
+
const queue = [...items.keys()].filter((id) => degree.get(id) === 0);
|
|
61
|
+
for (let i = 0; i < queue.length; i++) {
|
|
62
|
+
const id = queue[i];
|
|
63
|
+
order.push(items.get(id));
|
|
64
|
+
for (const to of next.get(id) ?? []) {
|
|
65
|
+
const left = degree.get(to) - 1;
|
|
66
|
+
degree.set(to, left);
|
|
67
|
+
if (left === 0) queue.push(to);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
calendar,
|
|
72
|
+
items,
|
|
73
|
+
leavesOf,
|
|
74
|
+
incoming,
|
|
75
|
+
outgoing,
|
|
76
|
+
order,
|
|
77
|
+
cycle: [...degree].filter(([, left]) => left > 0).map(([id]) => id)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** `at` moved by a link's lag, in working days or working minutes. */
|
|
81
|
+
function shiftByLag(calendar, at, link, dateOnly, sign = 1) {
|
|
82
|
+
const lag = parseLag(link.lag) ?? {
|
|
83
|
+
amount: 0,
|
|
84
|
+
unit: "day"
|
|
85
|
+
};
|
|
86
|
+
if (!lag.amount) return at;
|
|
87
|
+
const perDay = calendar.hours.reduce((sum, [open, close]) => sum + close - open, 0);
|
|
88
|
+
if (dateOnly) {
|
|
89
|
+
const days = lag.unit === "day" ? lag.amount : lag.amount / perDay;
|
|
90
|
+
return shiftWorkdays(calendar, Math.ceil(at / MINUTES_PER_DAY) * MINUTES_PER_DAY, sign * days);
|
|
91
|
+
}
|
|
92
|
+
const minutes = lag.unit === "day" ? lag.amount * perDay : lag.amount;
|
|
93
|
+
return shiftWorkMinutes(calendar, at, sign * minutes);
|
|
94
|
+
}
|
|
95
|
+
/** Where the `from` side of a link is: the start or the end its leaves cover. */
|
|
96
|
+
function anchorOf(graph, link) {
|
|
97
|
+
const type = link.type ?? "fs";
|
|
98
|
+
const leaves = graph.leavesOf(link.from);
|
|
99
|
+
return type === "fs" || type === "ff" ? Math.max(...leaves.map((item) => item.end)) : Math.min(...leaves.map((item) => item.start));
|
|
100
|
+
}
|
|
101
|
+
/** The time a link asks of its `to` side, and which end of it. */
|
|
102
|
+
function requirement(graph, link, dateOnly) {
|
|
103
|
+
const type = link.type ?? "fs";
|
|
104
|
+
return {
|
|
105
|
+
at: shiftByLag(graph.calendar, anchorOf(graph, link), link, dateOnly),
|
|
106
|
+
end: type === "ff" || type === "sf"
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** `a` is not before `b`, counting only working time. */
|
|
110
|
+
var notBefore = (calendar, a, b, dateOnly) => a >= b || workIn(calendar, a, b, dateOnly) === 0;
|
|
111
|
+
/** The earliest a whole-day milestone can stand at or after `at`: the end of a working day. */
|
|
112
|
+
function milestoneAt(calendar, at, dateOnly) {
|
|
113
|
+
if (!dateOnly) return nextWorkTime(calendar, at, false);
|
|
114
|
+
let day = Math.ceil(at / MINUTES_PER_DAY) - 1;
|
|
115
|
+
while (!calendar.isWorkday(day) && day < at / 1440 + 3660) day++;
|
|
116
|
+
return (day + 1) * MINUTES_PER_DAY;
|
|
117
|
+
}
|
|
118
|
+
/** The earliest start for a leaf that keeps all its incoming links, or `null` without any. */
|
|
119
|
+
function earliestStart(graph, item) {
|
|
120
|
+
const links = graph.incoming.get(item.task.id);
|
|
121
|
+
if (!links?.length) return null;
|
|
122
|
+
const { calendar } = graph;
|
|
123
|
+
let earliest = -Infinity;
|
|
124
|
+
for (const link of links) {
|
|
125
|
+
const need = requirement(graph, link, item.dateOnly);
|
|
126
|
+
let start;
|
|
127
|
+
if (item.milestone) start = need.at;
|
|
128
|
+
else if (need.end) start = startKeepingWork(calendar, item, need.at);
|
|
129
|
+
else start = need.at;
|
|
130
|
+
earliest = Math.max(earliest, start);
|
|
131
|
+
}
|
|
132
|
+
return item.milestone ? milestoneAt(calendar, earliest, item.dateOnly) : nextWorkTime(calendar, earliest, item.dateOnly);
|
|
133
|
+
}
|
|
134
|
+
/** Whether a link is kept by the spans in `graph`. */
|
|
135
|
+
function keeps(graph, link) {
|
|
136
|
+
const to = graph.leavesOf(link.to);
|
|
137
|
+
if (!to.length || !graph.leavesOf(link.from).length) return true;
|
|
138
|
+
return to.every((item) => {
|
|
139
|
+
const need = requirement(graph, link, item.dateOnly);
|
|
140
|
+
return notBefore(graph.calendar, need.end ? item.end : item.start, need.at, item.dateOnly);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Writes an item's span back as task dates. */
|
|
144
|
+
function written(item) {
|
|
145
|
+
const start = item.milestone && item.dateOnly ? item.start - MINUTES_PER_DAY / 2 : item.start;
|
|
146
|
+
return writeSpan(item.task, start, item.milestone ? start : item.end);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Moves tasks so that every link is kept: each task starts no earlier than
|
|
150
|
+
* its links allow, keeping its working time. By default tasks are only
|
|
151
|
+
* pushed later; `pull` also brings linked tasks earlier. Locked and fixed
|
|
152
|
+
* tasks stay, and links into them may stay broken.
|
|
153
|
+
*
|
|
154
|
+
* Takes time in proportion to the tasks and links. Nothing is changed:
|
|
155
|
+
* apply `moves` in a transaction, or call the chart's `schedule()`.
|
|
156
|
+
*/
|
|
157
|
+
function computeSchedule(doc, options = {}) {
|
|
158
|
+
const calendar = toCalendar(options.calendar);
|
|
159
|
+
const graph = buildGraph(doc, calendar);
|
|
160
|
+
const fixed = new Set(options.fixed);
|
|
161
|
+
const moves = /* @__PURE__ */ new Map();
|
|
162
|
+
for (const item of graph.order) {
|
|
163
|
+
if (item.task.locked || fixed.has(item.task.id)) continue;
|
|
164
|
+
const start = earliestStart(graph, item);
|
|
165
|
+
if (start == null) continue;
|
|
166
|
+
if (options.pull ? start === item.start : notBefore(calendar, item.start, start, item.dateOnly)) continue;
|
|
167
|
+
const end = item.milestone ? start : endKeepingWork(calendar, item, start);
|
|
168
|
+
item.start = start;
|
|
169
|
+
item.end = end;
|
|
170
|
+
moves.set(item.task.id, written(item));
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
moves,
|
|
174
|
+
violated: new Set(doc.links.filter((link) => !keeps(graph, link)).map((link) => link.id)),
|
|
175
|
+
cycle: graph.cycle
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Links whose `to` side starts (or ends) before the link allows; for manual scheduling. */
|
|
179
|
+
function findBrokenLinks(doc, options = {}) {
|
|
180
|
+
const graph = buildGraph(doc, toCalendar(options.calendar));
|
|
181
|
+
return new Set(doc.links.filter((link) => !keeps(graph, link)).map((link) => link.id));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The tasks that decide when the plan ends: working back from the last
|
|
185
|
+
* end, each task gets the latest end its successors allow, and the ones
|
|
186
|
+
* with no working time to spare are critical. Reads the dates as they
|
|
187
|
+
* are; tasks on a loop of links are left out.
|
|
188
|
+
*/
|
|
189
|
+
function computeCriticalPath(doc, options = {}) {
|
|
190
|
+
const cal = toCalendar(options.calendar);
|
|
191
|
+
const graph = buildGraph(doc, cal);
|
|
192
|
+
const out = {
|
|
193
|
+
tasks: /* @__PURE__ */ new Set(),
|
|
194
|
+
links: /* @__PURE__ */ new Set()
|
|
195
|
+
};
|
|
196
|
+
if (!graph.order.length) return out;
|
|
197
|
+
const finish = Math.max(...graph.order.map((item) => item.end));
|
|
198
|
+
/** The latest start and end of each leaf. */
|
|
199
|
+
const late = /* @__PURE__ */ new Map();
|
|
200
|
+
for (let i = graph.order.length - 1; i >= 0; i--) {
|
|
201
|
+
const item = graph.order[i];
|
|
202
|
+
let latestEnd = finish;
|
|
203
|
+
for (const link of graph.outgoing.get(item.task.id) ?? []) {
|
|
204
|
+
const type = link.type ?? "fs";
|
|
205
|
+
for (const to of graph.leavesOf(link.to)) {
|
|
206
|
+
const bound = late.get(to.task.id);
|
|
207
|
+
if (!bound) continue;
|
|
208
|
+
const at = shiftByLag(cal, type === "ff" || type === "sf" ? bound.end : bound.start, link, item.dateOnly, -1);
|
|
209
|
+
const end = type === "fs" || type === "ff" || item.milestone ? at : endKeepingWork(cal, item, at);
|
|
210
|
+
latestEnd = Math.min(latestEnd, end);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const latestStart = item.milestone ? latestEnd : startKeepingWork(cal, item, latestEnd);
|
|
214
|
+
late.set(item.task.id, {
|
|
215
|
+
start: latestStart,
|
|
216
|
+
end: latestEnd
|
|
217
|
+
});
|
|
218
|
+
if (workIn(cal, item.end, latestEnd, item.dateOnly) === 0) out.tasks.add(item.task.id);
|
|
219
|
+
}
|
|
220
|
+
for (const link of doc.links) {
|
|
221
|
+
const from = graph.leavesOf(link.from);
|
|
222
|
+
const to = graph.leavesOf(link.to);
|
|
223
|
+
if (!from.some((item) => out.tasks.has(item.task.id))) continue;
|
|
224
|
+
if (to.some((item) => {
|
|
225
|
+
if (!out.tasks.has(item.task.id)) return false;
|
|
226
|
+
const need = requirement(graph, link, item.dateOnly);
|
|
227
|
+
const at = need.end ? item.end : item.start;
|
|
228
|
+
return notBefore(cal, need.at, at, item.dateOnly);
|
|
229
|
+
})) out.links.add(link.id);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
export { computeCriticalPath, computeSchedule, findBrokenLinks };
|
|
234
|
+
|
|
235
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule.js","names":[],"sources":["../../src/core/schedule.ts"],"sourcesContent":["import {\n endKeepingWork,\n nextWorkTime,\n resolveCalendar,\n shiftWorkdays,\n shiftWorkMinutes,\n startKeepingWork,\n workIn,\n type GanttCalendar,\n type WorkCalendar,\n} from './calendar'\nimport { parseLag } from './links'\nimport { writeSpan, type GanttLink, type GanttTask } from './model'\nimport { MINUTES_PER_DAY } from './time'\nimport type { GanttDocument } from './transaction'\nimport { buildTree } from './tree'\n\n/*\n * Scheduling works on leaf tasks. A link to a summary applies to each of\n * its subtasks; a link from a summary reads the span its subtasks cover.\n * Every comparison is in working time, so a task that ends on Friday and\n * one that starts on Monday are back to back.\n *\n * A milestone is a point. Without a time it stands at the end of its day:\n * a task that finishes on Friday is followed by a milestone on Friday, and\n * a task after that milestone starts on Monday.\n */\n\nexport interface GanttScheduleOptions {\n /** Which days and hours are worked; the chart's `calendar`. */\n calendar?: GanttCalendar | WorkCalendar\n /** Also bring tasks earlier, to the earliest their links allow. Only tasks with links in move. */\n pull?: boolean\n /** Tasks to leave where they are, e.g. the ones just moved by hand; `locked` tasks always stay. */\n fixed?: Iterable<string>\n}\n\nexport interface GanttSchedule {\n /** The new dates of the tasks that move, written in each task's own style. */\n moves: Map<string, Pick<GanttTask, 'start' | 'end'>>\n /** Links still not kept afterwards: into a locked or fixed task, or on a loop. */\n violated: Set<string>\n /** Tasks on a loop of links, or after one; they are left where they are. */\n cycle: string[]\n}\n\nexport interface GanttCriticalPath {\n /** Tasks with no slack: any delay to them delays the end of the plan. */\n tasks: Set<string>\n /** Links between critical tasks that drive the later one. */\n links: Set<string>\n}\n\ninterface Item {\n task: GanttTask\n start: number\n end: number\n dateOnly: boolean\n milestone: boolean\n}\n\ninterface Graph {\n calendar: WorkCalendar\n items: Map<string, Item>\n /** Each task's scheduled leaves: itself for a leaf, its subtasks for a summary. */\n leavesOf: (id: string) => Item[]\n /** Links that point at each leaf. */\n incoming: Map<string, GanttLink[]>\n /** Links that leave from each leaf. */\n outgoing: Map<string, GanttLink[]>\n /** Leaves in an order where every link points forward. */\n order: Item[]\n /** Leaves left out of `order`: on or after a loop. */\n cycle: string[]\n}\n\nconst toCalendar = (calendar: GanttScheduleOptions['calendar']): WorkCalendar =>\n calendar && 'isWorkday' in calendar ? calendar : resolveCalendar(calendar)\n\nfunction push(map: Map<string, GanttLink[]>, id: string, link: GanttLink) {\n const list = map.get(id)\n if (list) list.push(link)\n else map.set(id, [link])\n}\n\nfunction buildGraph(doc: GanttDocument, calendar: WorkCalendar): Graph {\n const tree = buildTree(doc.tasks)\n const items = new Map<string, Item>()\n for (const row of tree.byId.values()) {\n if (row.summary || !row.span) continue\n const { start, end, dateOnly } = row.span\n // a whole-day milestone stands at the end of its day\n const at = row.milestone && dateOnly ? start + MINUTES_PER_DAY / 2 : start\n items.set(row.task.id, {\n task: row.task,\n start: at,\n end: row.milestone ? at : end,\n dateOnly,\n milestone: row.milestone,\n })\n }\n\n const leafCache = new Map<string, Item[]>()\n const leavesOf = (id: string): Item[] => {\n const cached = leafCache.get(id)\n if (cached) return cached\n const item = items.get(id)\n const out = item ? [item] : []\n if (!item) for (const child of tree.childrenOf.get(id) ?? []) out.push(...leavesOf(child.id))\n leafCache.set(id, out)\n return out\n }\n\n const incoming = new Map<string, GanttLink[]>()\n const outgoing = new Map<string, GanttLink[]>()\n const next = new Map<string, Set<string>>()\n const degree = new Map<string, number>()\n for (const id of items.keys()) degree.set(id, 0)\n for (const link of doc.links) {\n const from = leavesOf(link.from)\n const to = leavesOf(link.to)\n // a link between a summary and its own subtask means nothing\n if (!from.length || !to.length || from.some((item) => to.includes(item))) continue\n for (const item of to) push(incoming, item.task.id, link)\n for (const item of from) push(outgoing, item.task.id, link)\n for (const a of from) {\n let set = next.get(a.task.id)\n if (!set) next.set(a.task.id, (set = new Set()))\n for (const b of to) {\n if (set.has(b.task.id)) continue\n set.add(b.task.id)\n degree.set(b.task.id, degree.get(b.task.id)! + 1)\n }\n }\n }\n\n const order: Item[] = []\n const queue = [...items.keys()].filter((id) => degree.get(id) === 0)\n for (let i = 0; i < queue.length; i++) {\n const id = queue[i]!\n order.push(items.get(id)!)\n for (const to of next.get(id) ?? []) {\n const left = degree.get(to)! - 1\n degree.set(to, left)\n if (left === 0) queue.push(to)\n }\n }\n const cycle = [...degree].filter(([, left]) => left > 0).map(([id]) => id)\n return { calendar, items, leavesOf, incoming, outgoing, order, cycle }\n}\n\n/** `at` moved by a link's lag, in working days or working minutes. */\nfunction shiftByLag(\n calendar: WorkCalendar,\n at: number,\n link: GanttLink,\n dateOnly: boolean,\n sign = 1,\n) {\n const lag = parseLag(link.lag) ?? { amount: 0, unit: 'day' as const }\n if (!lag.amount) return at\n const perDay = calendar.hours.reduce((sum, [open, close]) => sum + close - open, 0)\n if (dateOnly) {\n const days = lag.unit === 'day' ? lag.amount : lag.amount / perDay\n // a part of a day still takes the day\n return shiftWorkdays(calendar, Math.ceil(at / MINUTES_PER_DAY) * MINUTES_PER_DAY, sign * days)\n }\n const minutes = lag.unit === 'day' ? lag.amount * perDay : lag.amount\n return shiftWorkMinutes(calendar, at, sign * minutes)\n}\n\n/** Where the `from` side of a link is: the start or the end its leaves cover. */\nfunction anchorOf(graph: Graph, link: GanttLink) {\n const type = link.type ?? 'fs'\n const leaves = graph.leavesOf(link.from)\n return type === 'fs' || type === 'ff'\n ? Math.max(...leaves.map((item) => item.end))\n : Math.min(...leaves.map((item) => item.start))\n}\n\n/** The time a link asks of its `to` side, and which end of it. */\nfunction requirement(graph: Graph, link: GanttLink, dateOnly: boolean) {\n const type = link.type ?? 'fs'\n const at = shiftByLag(graph.calendar, anchorOf(graph, link), link, dateOnly)\n return { at, end: type === 'ff' || type === 'sf' }\n}\n\n/** `a` is not before `b`, counting only working time. */\nconst notBefore = (calendar: WorkCalendar, a: number, b: number, dateOnly: boolean) =>\n a >= b || workIn(calendar, a, b, dateOnly) === 0\n\n/** The earliest a whole-day milestone can stand at or after `at`: the end of a working day. */\nfunction milestoneAt(calendar: WorkCalendar, at: number, dateOnly: boolean) {\n if (!dateOnly) return nextWorkTime(calendar, at, false)\n let day = Math.ceil(at / MINUTES_PER_DAY) - 1\n while (!calendar.isWorkday(day) && day < at / MINUTES_PER_DAY + 3660) day++\n return (day + 1) * MINUTES_PER_DAY\n}\n\n/** The earliest start for a leaf that keeps all its incoming links, or `null` without any. */\nfunction earliestStart(graph: Graph, item: Item) {\n const links = graph.incoming.get(item.task.id)\n if (!links?.length) return null\n const { calendar } = graph\n let earliest = -Infinity\n for (const link of links) {\n const need = requirement(graph, link, item.dateOnly)\n let start: number\n if (item.milestone) start = need.at\n else if (need.end) start = startKeepingWork(calendar, item, need.at)\n else start = need.at\n earliest = Math.max(earliest, start)\n }\n return item.milestone\n ? milestoneAt(calendar, earliest, item.dateOnly)\n : nextWorkTime(calendar, earliest, item.dateOnly)\n}\n\n/** Whether a link is kept by the spans in `graph`. */\nfunction keeps(graph: Graph, link: GanttLink) {\n const to = graph.leavesOf(link.to)\n if (!to.length || !graph.leavesOf(link.from).length) return true\n return to.every((item) => {\n const need = requirement(graph, link, item.dateOnly)\n return notBefore(graph.calendar, need.end ? item.end : item.start, need.at, item.dateOnly)\n })\n}\n\n/** Writes an item's span back as task dates. */\nfunction written(item: Item) {\n const start = item.milestone && item.dateOnly ? item.start - MINUTES_PER_DAY / 2 : item.start\n return writeSpan(item.task, start, item.milestone ? start : item.end)\n}\n\n/**\n * Moves tasks so that every link is kept: each task starts no earlier than\n * its links allow, keeping its working time. By default tasks are only\n * pushed later; `pull` also brings linked tasks earlier. Locked and fixed\n * tasks stay, and links into them may stay broken.\n *\n * Takes time in proportion to the tasks and links. Nothing is changed:\n * apply `moves` in a transaction, or call the chart's `schedule()`.\n */\nexport function computeSchedule(\n doc: GanttDocument,\n options: GanttScheduleOptions = {},\n): GanttSchedule {\n const calendar = toCalendar(options.calendar)\n const graph = buildGraph(doc, calendar)\n const fixed = new Set(options.fixed)\n const moves = new Map<string, Pick<GanttTask, 'start' | 'end'>>()\n for (const item of graph.order) {\n if (item.task.locked || fixed.has(item.task.id)) continue\n const start = earliestStart(graph, item)\n if (start == null) continue\n if (options.pull ? start === item.start : notBefore(calendar, item.start, start, item.dateOnly))\n continue\n const end = item.milestone ? start : endKeepingWork(calendar, item, start)\n item.start = start\n item.end = end\n moves.set(item.task.id, written(item))\n }\n const violated = new Set(doc.links.filter((link) => !keeps(graph, link)).map((link) => link.id))\n return { moves, violated, cycle: graph.cycle }\n}\n\n/** Links whose `to` side starts (or ends) before the link allows; for manual scheduling. */\nexport function findBrokenLinks(\n doc: GanttDocument,\n options: Pick<GanttScheduleOptions, 'calendar'> = {},\n) {\n const graph = buildGraph(doc, toCalendar(options.calendar))\n return new Set(doc.links.filter((link) => !keeps(graph, link)).map((link) => link.id))\n}\n\n/**\n * The tasks that decide when the plan ends: working back from the last\n * end, each task gets the latest end its successors allow, and the ones\n * with no working time to spare are critical. Reads the dates as they\n * are; tasks on a loop of links are left out.\n */\nexport function computeCriticalPath(\n doc: GanttDocument,\n options: Pick<GanttScheduleOptions, 'calendar'> = {},\n): GanttCriticalPath {\n const cal = toCalendar(options.calendar)\n const graph = buildGraph(doc, cal)\n const out: GanttCriticalPath = { tasks: new Set(), links: new Set() }\n if (!graph.order.length) return out\n const finish = Math.max(...graph.order.map((item) => item.end))\n /** The latest start and end of each leaf. */\n const late = new Map<string, { start: number; end: number }>()\n\n for (let i = graph.order.length - 1; i >= 0; i--) {\n const item = graph.order[i]!\n let latestEnd = finish\n for (const link of graph.outgoing.get(item.task.id) ?? []) {\n const type = link.type ?? 'fs'\n for (const to of graph.leavesOf(link.to)) {\n const bound = late.get(to.task.id)\n if (!bound) continue\n const at = shiftByLag(\n cal,\n type === 'ff' || type === 'sf' ? bound.end : bound.start,\n link,\n item.dateOnly,\n -1,\n )\n // a link from the start bounds the start; turn it into an end\n const end =\n type === 'fs' || type === 'ff' || item.milestone ? at : endKeepingWork(cal, item, at)\n latestEnd = Math.min(latestEnd, end)\n }\n }\n const latestStart = item.milestone ? latestEnd : startKeepingWork(cal, item, latestEnd)\n late.set(item.task.id, { start: latestStart, end: latestEnd })\n if (workIn(cal, item.end, latestEnd, item.dateOnly) === 0) out.tasks.add(item.task.id)\n }\n\n for (const link of doc.links) {\n const from = graph.leavesOf(link.from)\n const to = graph.leavesOf(link.to)\n if (!from.some((item) => out.tasks.has(item.task.id))) continue\n // the link drives a critical task when that task sits right where the link allows\n const driven = to.some((item) => {\n if (!out.tasks.has(item.task.id)) return false\n const need = requirement(graph, link, item.dateOnly)\n const at = need.end ? item.end : item.start\n return notBefore(cal, need.at, at, item.dateOnly)\n })\n if (driven) out.links.add(link.id)\n }\n return out\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/core/time.ts
|
|
2
|
+
var MINUTES_PER_DAY = 1440;
|
|
3
|
+
var PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::\d{2})?)?$/;
|
|
4
|
+
/** Strings already read: a plan repeats the same dates, and reads them all on every change. */
|
|
5
|
+
var parsed = /* @__PURE__ */ new Map();
|
|
6
|
+
var PARSED_MAX = 5e4;
|
|
7
|
+
/**
|
|
8
|
+
* Reads `YYYY-MM-DD` or `YYYY-MM-DD HH:mm`; seconds (`HH:mm:ss`) are
|
|
9
|
+
* accepted and dropped. `null` for anything else.
|
|
10
|
+
*/
|
|
11
|
+
function parseTime(text) {
|
|
12
|
+
if (!text) return null;
|
|
13
|
+
let result = parsed.get(text);
|
|
14
|
+
if (result !== void 0) return result;
|
|
15
|
+
result = readTime(text);
|
|
16
|
+
if (parsed.size >= PARSED_MAX) parsed.clear();
|
|
17
|
+
parsed.set(text, result);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function readTime(text) {
|
|
21
|
+
const match = PATTERN.exec(text.trim());
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const [, y, mo, d, h, mi] = match;
|
|
24
|
+
const month = Number(mo);
|
|
25
|
+
const day = Number(d);
|
|
26
|
+
const hour = h == null ? 0 : Number(h);
|
|
27
|
+
const minute = mi == null ? 0 : Number(mi);
|
|
28
|
+
if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59) return null;
|
|
29
|
+
const at = fromParts(Number(y), month, day, hour, minute);
|
|
30
|
+
if (partsOf(at).day !== day) return null;
|
|
31
|
+
return {
|
|
32
|
+
at,
|
|
33
|
+
dateOnly: h == null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Minutes since the epoch for a wall-clock date; `month` counts from 1. */
|
|
37
|
+
function fromParts(year, month, day, hour = 0, minute = 0) {
|
|
38
|
+
return Date.UTC(year, month - 1, day, hour, minute) / 6e4;
|
|
39
|
+
}
|
|
40
|
+
function partsOf(at) {
|
|
41
|
+
const date = /* @__PURE__ */ new Date(at * 6e4);
|
|
42
|
+
return {
|
|
43
|
+
year: date.getUTCFullYear(),
|
|
44
|
+
month: date.getUTCMonth() + 1,
|
|
45
|
+
day: date.getUTCDate(),
|
|
46
|
+
weekday: date.getUTCDay(),
|
|
47
|
+
hour: date.getUTCHours(),
|
|
48
|
+
minute: date.getUTCMinutes()
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
52
|
+
/** `YYYY-MM-DD`. */
|
|
53
|
+
function formatDate(at) {
|
|
54
|
+
const p = partsOf(at);
|
|
55
|
+
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
|
56
|
+
}
|
|
57
|
+
/** `YYYY-MM-DD HH:mm`, or `YYYY-MM-DD` when `dateOnly`. */
|
|
58
|
+
function formatTime(at, dateOnly = false) {
|
|
59
|
+
if (dateOnly) return formatDate(at);
|
|
60
|
+
const p = partsOf(at);
|
|
61
|
+
return `${formatDate(at)} ${pad(p.hour)}:${pad(p.minute)}`;
|
|
62
|
+
}
|
|
63
|
+
/** `HH:mm` as minutes since midnight, or `null`. */
|
|
64
|
+
function parseClock(text) {
|
|
65
|
+
const match = text ? /^(\d{1,2}):(\d{2})$/.exec(text) : null;
|
|
66
|
+
if (!match) return null;
|
|
67
|
+
const minutes = Number(match[1]) * 60 + Number(match[2]);
|
|
68
|
+
return minutes <= 1440 ? minutes : null;
|
|
69
|
+
}
|
|
70
|
+
/** The midnight that starts the day of `at`. */
|
|
71
|
+
var dayStart = (at) => Math.floor(at / MINUTES_PER_DAY) * MINUTES_PER_DAY;
|
|
72
|
+
/** Days since the epoch. */
|
|
73
|
+
var dayIndex = (at) => Math.floor(at / MINUTES_PER_DAY);
|
|
74
|
+
var isMidnight = (at) => at % MINUTES_PER_DAY === 0;
|
|
75
|
+
/** The current wall-clock time, in minutes since the epoch. */
|
|
76
|
+
function wallClockNow(now = /* @__PURE__ */ new Date()) {
|
|
77
|
+
return fromParts(now.getFullYear(), now.getMonth() + 1, now.getDate(), now.getHours(), now.getMinutes());
|
|
78
|
+
}
|
|
79
|
+
export { MINUTES_PER_DAY, dayIndex, dayStart, formatDate, formatTime, fromParts, isMidnight, parseClock, parseTime, partsOf, wallClockNow };
|
|
80
|
+
|
|
81
|
+
//# sourceMappingURL=time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.js","names":[],"sources":["../../src/core/time.ts"],"sourcesContent":["/*\n * Times are minutes since 1970-01-01 00:00 on the wall clock, kept as plain\n * numbers. Time zones and daylight saving never enter: `2026-03-05 09:00`\n * is the same number wherever the chart runs. Strings are read on the way\n * in and written on the way out; everything in between is arithmetic.\n */\n\nexport const MINUTES_PER_HOUR = 60\nexport const MINUTES_PER_DAY = 1440\n\nconst PATTERN = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{2}):(\\d{2})(?::\\d{2})?)?$/\n\nexport interface ParsedTime {\n /** Minutes since the epoch. */\n at: number\n /** The string had no time of day. */\n dateOnly: boolean\n}\n\n/** Strings already read: a plan repeats the same dates, and reads them all on every change. */\nconst parsed = new Map<string, Readonly<ParsedTime> | null>()\nconst PARSED_MAX = 50_000\n\n/**\n * Reads `YYYY-MM-DD` or `YYYY-MM-DD HH:mm`; seconds (`HH:mm:ss`) are\n * accepted and dropped. `null` for anything else.\n */\nexport function parseTime(text: string | undefined | null): Readonly<ParsedTime> | null {\n if (!text) return null\n let result = parsed.get(text)\n if (result !== undefined) return result\n result = readTime(text)\n if (parsed.size >= PARSED_MAX) parsed.clear()\n parsed.set(text, result)\n return result\n}\n\nfunction readTime(text: string): ParsedTime | null {\n const match = PATTERN.exec(text.trim())\n if (!match) return null\n const [, y, mo, d, h, mi] = match\n const month = Number(mo)\n const day = Number(d)\n const hour = h == null ? 0 : Number(h)\n const minute = mi == null ? 0 : Number(mi)\n if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59) return null\n const at = fromParts(Number(y), month, day, hour, minute)\n // reject days past the end of the month, e.g. 02-30\n if (partsOf(at).day !== day) return null\n return { at, dateOnly: h == null }\n}\n\n/** Minutes since the epoch for a wall-clock date; `month` counts from 1. */\nexport function fromParts(year: number, month: number, day: number, hour = 0, minute = 0) {\n return Date.UTC(year, month - 1, day, hour, minute) / 60_000\n}\n\nexport interface TimeParts {\n year: number\n /** From 1. */\n month: number\n day: number\n /** `0` is Sunday. */\n weekday: number\n hour: number\n minute: number\n}\n\nexport function partsOf(at: number): TimeParts {\n const date = new Date(at * 60_000)\n return {\n year: date.getUTCFullYear(),\n month: date.getUTCMonth() + 1,\n day: date.getUTCDate(),\n weekday: date.getUTCDay(),\n hour: date.getUTCHours(),\n minute: date.getUTCMinutes(),\n }\n}\n\nconst pad = (n: number) => String(n).padStart(2, '0')\n\n/** `YYYY-MM-DD`. */\nexport function formatDate(at: number) {\n const p = partsOf(at)\n return `${p.year}-${pad(p.month)}-${pad(p.day)}`\n}\n\n/** `YYYY-MM-DD HH:mm`, or `YYYY-MM-DD` when `dateOnly`. */\nexport function formatTime(at: number, dateOnly = false) {\n if (dateOnly) return formatDate(at)\n const p = partsOf(at)\n return `${formatDate(at)} ${pad(p.hour)}:${pad(p.minute)}`\n}\n\n/** `HH:mm` as minutes since midnight, or `null`. */\nexport function parseClock(text: string | undefined) {\n const match = text ? /^(\\d{1,2}):(\\d{2})$/.exec(text) : null\n if (!match) return null\n const minutes = Number(match[1]) * 60 + Number(match[2])\n return minutes <= MINUTES_PER_DAY ? minutes : null\n}\n\n/** The midnight that starts the day of `at`. */\nexport const dayStart = (at: number) => Math.floor(at / MINUTES_PER_DAY) * MINUTES_PER_DAY\n\n/** Days since the epoch. */\nexport const dayIndex = (at: number) => Math.floor(at / MINUTES_PER_DAY)\n\nexport const isMidnight = (at: number) => at % MINUTES_PER_DAY === 0\n\n/** The current wall-clock time, in minutes since the epoch. */\nexport function wallClockNow(now = new Date()) {\n return fromParts(\n now.getFullYear(),\n now.getMonth() + 1,\n now.getDate(),\n now.getHours(),\n now.getMinutes(),\n )\n}\n"],"mappings":";AAQA,IAAa,kBAAkB;AAE/B,IAAM,UAAU;;AAUhB,IAAM,yBAAS,IAAI,IAAyC;AAC5D,IAAM,aAAa;;;;;AAMnB,SAAgB,UAAU,MAA8D;CACtF,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,SAAS,OAAO,IAAI,IAAI;CAC5B,IAAI,WAAW,KAAA,GAAW,OAAO;CACjC,SAAS,SAAS,IAAI;CACtB,IAAI,OAAO,QAAQ,YAAY,OAAO,MAAM;CAC5C,OAAO,IAAI,MAAM,MAAM;CACvB,OAAO;AACT;AAEA,SAAS,SAAS,MAAiC;CACjD,MAAM,QAAQ,QAAQ,KAAK,KAAK,KAAK,CAAC;CACtC,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,GAAG,GAAG,IAAI,GAAG,GAAG,MAAM;CAC5B,MAAM,QAAQ,OAAO,EAAE;CACvB,MAAM,MAAM,OAAO,CAAC;CACpB,MAAM,OAAO,KAAK,OAAO,IAAI,OAAO,CAAC;CACrC,MAAM,SAAS,MAAM,OAAO,IAAI,OAAO,EAAE;CACzC,IAAI,QAAQ,KAAK,QAAQ,MAAM,MAAM,KAAK,OAAO,MAAM,SAAS,IAAI,OAAO;CAC3E,MAAM,KAAK,UAAU,OAAO,CAAC,GAAG,OAAO,KAAK,MAAM,MAAM;CAExD,IAAI,QAAQ,EAAE,CAAC,CAAC,QAAQ,KAAK,OAAO;CACpC,OAAO;EAAE;EAAI,UAAU,KAAK;CAAK;AACnC;;AAGA,SAAgB,UAAU,MAAc,OAAe,KAAa,OAAO,GAAG,SAAS,GAAG;CACxF,OAAO,KAAK,IAAI,MAAM,QAAQ,GAAG,KAAK,MAAM,MAAM,IAAI;AACxD;AAaA,SAAgB,QAAQ,IAAuB;CAC7C,MAAM,uBAAO,IAAI,KAAK,KAAK,GAAM;CACjC,OAAO;EACL,MAAM,KAAK,eAAe;EAC1B,OAAO,KAAK,YAAY,IAAI;EAC5B,KAAK,KAAK,WAAW;EACrB,SAAS,KAAK,UAAU;EACxB,MAAM,KAAK,YAAY;EACvB,QAAQ,KAAK,cAAc;CAC7B;AACF;AAEA,IAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;;AAGpD,SAAgB,WAAW,IAAY;CACrC,MAAM,IAAI,QAAQ,EAAE;CACpB,OAAO,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,IAAI,EAAE,GAAG;AAC/C;;AAGA,SAAgB,WAAW,IAAY,WAAW,OAAO;CACvD,IAAI,UAAU,OAAO,WAAW,EAAE;CAClC,MAAM,IAAI,QAAQ,EAAE;CACpB,OAAO,GAAG,WAAW,EAAE,EAAE,GAAG,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,MAAM;AACzD;;AAGA,SAAgB,WAAW,MAA0B;CACnD,MAAM,QAAQ,OAAO,sBAAsB,KAAK,IAAI,IAAI;CACxD,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,UAAU,OAAO,MAAM,EAAE,IAAI,KAAK,OAAO,MAAM,EAAE;CACvD,OAAO,WAAA,OAA6B,UAAU;AAChD;;AAGA,IAAa,YAAY,OAAe,KAAK,MAAM,KAAK,eAAe,IAAI;;AAG3E,IAAa,YAAY,OAAe,KAAK,MAAM,KAAK,eAAe;AAEvE,IAAa,cAAc,OAAe,KAAK,oBAAoB;;AAGnE,SAAgB,aAAa,sBAAM,IAAI,KAAK,GAAG;CAC7C,OAAO,UACL,IAAI,YAAY,GAChB,IAAI,SAAS,IAAI,GACjB,IAAI,QAAQ,GACZ,IAAI,SAAS,GACb,IAAI,WAAW,CACjB;AACF"}
|