flexdesk 0.4.6 → 0.5.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/README.md +140 -0
- package/css/base.css +123 -2
- package/css/flexdesk.css +127 -4
- package/css/overrides.css +4 -2
- package/css/tokens.css +14 -0
- package/dist/charts.js +3 -3
- package/dist/{chunk-ELXVW542.js → chunk-24MS7W5Y.js} +2 -2
- package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
- package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
- package/dist/{chunk-HWCX5I4R.js → chunk-CNXP3SIN.js} +3 -3
- package/dist/{chunk-CYTWMP2Y.js → chunk-KH2GJTKR.js} +1112 -32
- package/dist/chunk-KH2GJTKR.js.map +7 -0
- package/dist/flexdesk.css +127 -4
- package/dist/tiles.js +3 -3
- package/dist/tokens.css +14 -0
- package/dist/widgets.js +31 -5
- package/dist/widgets.js.map +1 -1
- package/dist/wm.js +79 -19
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/tiling/landing_table.js +13 -5
- package/src/tiling/tile_breadcrumb.js +21 -0
- package/src/tiling/tile_renderer.js +11 -0
- package/src/tiling/wm.js +62 -18
- package/src/ui/components/data_table.js +961 -37
- package/src/ui/components/row_activation.js +70 -0
- package/src/ui/components/table_values.js +423 -0
- package/dist/chunk-CYTWMP2Y.js.map +0 -7
- /package/dist/{chunk-ELXVW542.js.map → chunk-24MS7W5Y.js.map} +0 -0
- /package/dist/{chunk-HWCX5I4R.js.map → chunk-CNXP3SIN.js.map} +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* row_activation.js — WHEN A ROW GESTURE OPENS THE ROW. One rule, shared by
|
|
3
|
+
* `DataTable`'s `onRowActivate` (0.5.0) and the landing helper
|
|
4
|
+
* `attachLandingTableBehavior`, so a list built either way opens on exactly the
|
|
5
|
+
* same gestures and refuses exactly the same ones.
|
|
6
|
+
*
|
|
7
|
+
* A row "activates" — opens the thing it stands for — on a click, a double-click
|
|
8
|
+
* or Enter, as the table asks. Three things that look like a click are not one:
|
|
9
|
+
*
|
|
10
|
+
* 1. **A control in the row is its own gesture.** A button, a link, a field, a
|
|
11
|
+
* `[data-twm-action]` verb: pressing it does what it says, and must not ALSO
|
|
12
|
+
* open the row underneath it.
|
|
13
|
+
* 2. **Dragging across a cell to copy it is not a click on the row.** A drag
|
|
14
|
+
* that ends a text selection fires `click` on the cell it ends on; the
|
|
15
|
+
* selection being non-empty is how it is told apart. Only a selection that
|
|
16
|
+
* touches the list counts — one elsewhere on the page is not this gesture.
|
|
17
|
+
* 3. **The second click of a double-click opens nothing.** A double-click is
|
|
18
|
+
* two `click`s (`detail` 1, then 2) before the `dblclick`; the first one has
|
|
19
|
+
* already opened the row. A list that draws the thing it opened IN PLACE
|
|
20
|
+
* would otherwise take the second click as a click on whatever is now under
|
|
21
|
+
* the pointer. `detail` is the platform's click count, not a timing guess;
|
|
22
|
+
* a synthetic or keyboard `click` carries 0 and is a single click.
|
|
23
|
+
*
|
|
24
|
+
* A `dblclick` gesture skips rule 2: a double-click on text selects the word
|
|
25
|
+
* under it, so a selection is the ordinary result of the gesture, not evidence
|
|
26
|
+
* of a drag.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What a press inside a row is never a ROW gesture on: a control or a verb. */
|
|
30
|
+
export const ROW_CONTROL_SELECTOR = [
|
|
31
|
+
'button', 'a', 'input', 'select', 'textarea', 'label', 'summary',
|
|
32
|
+
'[contenteditable=""]', '[contenteditable="true"]', '[data-twm-action]',
|
|
33
|
+
].join(', ');
|
|
34
|
+
|
|
35
|
+
/** Did this press land on a control inside the row (rule 1)? */
|
|
36
|
+
export function isRowControl(target, scope = null) {
|
|
37
|
+
const hit = target?.closest?.(ROW_CONTROL_SELECTOR);
|
|
38
|
+
return !!hit && (!scope || scope.contains(hit));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does a non-empty text selection touch `scope` (rule 2)? Without a scope,
|
|
42
|
+
* any non-empty selection in the target's document counts. */
|
|
43
|
+
export function endsTextSelection(target, scope = null) {
|
|
44
|
+
const doc = target?.ownerDocument || (typeof document !== 'undefined' ? document : null);
|
|
45
|
+
const sel = doc?.getSelection?.();
|
|
46
|
+
if (!sel || sel.isCollapsed || !String(sel)) return false;
|
|
47
|
+
if (!scope) return true;
|
|
48
|
+
const inside = (node) => !!node && scope.contains(node);
|
|
49
|
+
return inside(sel.anchorNode) || inside(sel.focusNode);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Is this event a row activation of kind `gesture`, by the three rules above?
|
|
54
|
+
*
|
|
55
|
+
* @param {Event} ev
|
|
56
|
+
* @param {'click'|'dblclick'} [gesture='click']
|
|
57
|
+
* @param {{scope?: Element}} [o] the list's own element: a control or a
|
|
58
|
+
* selection outside it is not this list's business
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isRowActivation(ev, gesture = 'click', { scope = null } = {}) {
|
|
62
|
+
const target = ev?.target;
|
|
63
|
+
if (!target?.closest) return false;
|
|
64
|
+
if (isRowControl(target, scope)) return false;
|
|
65
|
+
if (gesture === 'click') {
|
|
66
|
+
if ((ev.detail ?? 0) > 1) return false;
|
|
67
|
+
if (endsTextSelection(target, scope)) return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table_values.js — the REAL VALUE behind a date, a timestamp or a duration
|
|
3
|
+
* cell, and the words a reader sees for it.
|
|
4
|
+
*
|
|
5
|
+
* A list that hands DataTable already-formatted text sorts that text: "10/5/2026"
|
|
6
|
+
* before "9/30/2026", "3.2 s" before "850 ms", "5 Oct" before "30 Sep". The
|
|
7
|
+
* cure is to hand the table the VALUE (an ISO string, a `Date`, epoch
|
|
8
|
+
* milliseconds, a duration in milliseconds) and let it format for the eye while
|
|
9
|
+
* it sorts and filters by the value. That is what a column typed `date`,
|
|
10
|
+
* `datetime` or `duration` does (0.5.0), and these are the functions it uses.
|
|
11
|
+
*
|
|
12
|
+
* They are exported (through `data_table.js`) so a consumer that draws a value
|
|
13
|
+
* somewhere else — a detail pane, a tooltip — writes the same words the table
|
|
14
|
+
* writes, rather than an eighth private formatter.
|
|
15
|
+
*
|
|
16
|
+
* ══ THE DEFAULT IS ISO ═══════════════════════════════════════════════
|
|
17
|
+
*
|
|
18
|
+
* `YYYY-MM-DD` for a date and `YYYY-MM-DD HH:mm` for a timestamp. It sorts as
|
|
19
|
+
* text, it reads the same in every locale, and it is what the product owner of
|
|
20
|
+
* the first consumer asked for: *"Flexdesk default should be the ISO date"*. A
|
|
21
|
+
* consumer that wants something else passes a pattern (below) or a function.
|
|
22
|
+
*
|
|
23
|
+
* ══ PATTERNS ═════════════════════════════════════════════════════════
|
|
24
|
+
*
|
|
25
|
+
* YYYY 2026 YY 26 MMMM October MMM Oct MM 10 M 10
|
|
26
|
+
* DD 05 D 5 dddd Monday ddd Mon
|
|
27
|
+
* HH 14 H 14 hh 02 h 2 mm 05 m 5
|
|
28
|
+
* ss 09 s 9 SSS 042 A PM a pm
|
|
29
|
+
* [literal] anything in square brackets is copied verbatim
|
|
30
|
+
*
|
|
31
|
+
* Month and weekday NAMES are English. A consumer that needs another language
|
|
32
|
+
* passes a function, which can use `Intl.DateTimeFormat` with whatever locale it
|
|
33
|
+
* likes; this module has no locale data and wants none.
|
|
34
|
+
*
|
|
35
|
+
* ══ TIME ZONES ═══════════════════════════════════════════════════════
|
|
36
|
+
*
|
|
37
|
+
* A value WITH a zone (`…Z`, `…+02:00`, a `Date`, epoch ms) is an instant, and
|
|
38
|
+
* it is drawn in the reader's local zone unless `utc` is asked for. A value
|
|
39
|
+
* WITHOUT one (`2026-10-05`, `2026-10-05 14:30`) is a wall-clock reading, and
|
|
40
|
+
* it is taken in the zone it will be drawn in — so `2026-10-05` is drawn as
|
|
41
|
+
* `2026-10-05` everywhere. (`Date.parse('2026-10-05')` reads it as UTC
|
|
42
|
+
* midnight, which west of Greenwich is the evening of the 4th: a calendar date
|
|
43
|
+
* that moves a day depending on where it is read.)
|
|
44
|
+
*
|
|
45
|
+
* ══ WHAT IS READ AS A DATE: ISO 8601, A `Date`, EPOCH MS — NOTHING ELSE ══
|
|
46
|
+
*
|
|
47
|
+
* Text that is not ISO 8601 is NOT handed to `Date.parse`. Its other forms are
|
|
48
|
+
* implementation-defined, and V8's reads almost anything: `'2'`, `'12'`,
|
|
49
|
+
* `'1/2'` and `'5 Oct'` are all dates in 2001 there. A cell holding `'12'` was
|
|
50
|
+
* drawn `2001-12-01`, and a date filter being typed — `2`, `20`, `202`,
|
|
51
|
+
* `2026-` — emptied the table at every other keystroke, because each half was
|
|
52
|
+
* read as an instant nothing equalled. So: an ISO string, a `Date` or a
|
|
53
|
+
* number, or it is text — drawn as it came, sorted last, and matched by a
|
|
54
|
+
* filter's words. An impossible calendar date (`2026-02-31`) is text too, not
|
|
55
|
+
* the 3rd of March it would roll over to.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
|
|
59
|
+
'August', 'September', 'October', 'November', 'December'];
|
|
60
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
|
61
|
+
'Friday', 'Saturday'];
|
|
62
|
+
|
|
63
|
+
/** The ISO default for a `date` column. */
|
|
64
|
+
export const ISO_DATE = 'YYYY-MM-DD';
|
|
65
|
+
/** The ISO default for a `datetime` column. */
|
|
66
|
+
export const ISO_DATETIME = 'YYYY-MM-DD HH:mm';
|
|
67
|
+
|
|
68
|
+
// An ISO 8601 date, optionally with a time (`T` or a space) and a zone.
|
|
69
|
+
const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
|
|
70
|
+
|
|
71
|
+
const pad = (n, w = 2) => String(Math.trunc(Math.abs(n))).padStart(w, '0');
|
|
72
|
+
|
|
73
|
+
/** Days in a month (0-based), proleptic Gregorian — as `Date` counts them. */
|
|
74
|
+
function daysInMonth(y, mo) {
|
|
75
|
+
if (mo !== 1) return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][mo];
|
|
76
|
+
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Epoch ms for calendar parts (month 0-based), in UTC or the local zone. Not
|
|
81
|
+
* `new Date(y, …)` or `Date.UTC(y, …)`: both read a year below 100 as 19xx, so
|
|
82
|
+
* `0001-01-01` — a common "no date" sentinel — would be drawn `1901-01-01`.
|
|
83
|
+
* Parts past their range roll over, which is what a period's END wants; a
|
|
84
|
+
* VALUE is checked before it gets here.
|
|
85
|
+
*/
|
|
86
|
+
function partsToTime(utc, y, mo, d = 1, h = 0, mi = 0, s = 0, ms = 0) {
|
|
87
|
+
const date = new Date(0);
|
|
88
|
+
if (utc) {
|
|
89
|
+
date.setUTCFullYear(y, mo, d);
|
|
90
|
+
date.setUTCHours(h, mi, s, ms);
|
|
91
|
+
} else {
|
|
92
|
+
date.setFullYear(y, mo, d);
|
|
93
|
+
date.setHours(h, mi, s, ms);
|
|
94
|
+
}
|
|
95
|
+
return date.getTime();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Epoch milliseconds for a date-like value, or `NaN` when it is not one.
|
|
100
|
+
*
|
|
101
|
+
* @param {Date|number|string|null|undefined} value a `Date`, epoch ms, or an
|
|
102
|
+
* ISO 8601 string. Any other text is `NaN` — see "What is read as a date"
|
|
103
|
+
* in the header — and so is a calendar date that does not exist
|
|
104
|
+
* (`2026-02-31`, `2026-02-29`).
|
|
105
|
+
* @param {{utc?: boolean}} [o] read a zone-less string as UTC rather than local
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
export function parseDateValue(value, { utc = false } = {}) {
|
|
109
|
+
if (value == null || value === '') return NaN;
|
|
110
|
+
if (value instanceof Date) return value.getTime();
|
|
111
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN;
|
|
112
|
+
const text = String(value).trim();
|
|
113
|
+
if (!text) return NaN;
|
|
114
|
+
const m = ISO_RE.exec(text);
|
|
115
|
+
if (!m) return NaN;
|
|
116
|
+
const y = Number(m[1]);
|
|
117
|
+
const mo = Number(m[2]);
|
|
118
|
+
const d = Number(m[3]);
|
|
119
|
+
if (mo < 1 || mo > 12 || d < 1 || d > daysInMonth(y, mo - 1)) return NaN;
|
|
120
|
+
const h = m[4] ? Number(m[4]) : 0;
|
|
121
|
+
const mi = m[5] ? Number(m[5]) : 0;
|
|
122
|
+
const s = m[6] ? Number(m[6]) : 0;
|
|
123
|
+
const ms = m[7] ? Math.round(Number(`0.${m[7]}`) * 1000) : 0;
|
|
124
|
+
// 24:00 is the end of the day (ISO 8601); 24:01 is nothing. A leap second
|
|
125
|
+
// (:60) is the next minute's first.
|
|
126
|
+
if (h > 24 || mi > 59 || s > 60 || (h === 24 && (mi || s || ms))) return NaN;
|
|
127
|
+
const zone = m[8];
|
|
128
|
+
if (zone) {
|
|
129
|
+
let offset = 0;
|
|
130
|
+
if (zone.toUpperCase() !== 'Z') {
|
|
131
|
+
const sign = zone[0] === '-' ? -1 : 1;
|
|
132
|
+
const digits = zone.slice(1).replace(':', '');
|
|
133
|
+
offset = sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4) || 0));
|
|
134
|
+
}
|
|
135
|
+
return partsToTime(true, y, mo - 1, d, h, mi, s, ms) - offset * 60000;
|
|
136
|
+
}
|
|
137
|
+
return partsToTime(utc, y, mo - 1, d, h, mi, s, ms);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Draw a date-like value with a pattern (see the header). An unreadable value
|
|
142
|
+
* is drawn AS IT CAME rather than as nothing: a blank cell would hide that the
|
|
143
|
+
* data held something unexpected.
|
|
144
|
+
*
|
|
145
|
+
* @param {Date|number|string|null|undefined} value
|
|
146
|
+
* @param {string|Function} [pattern='YYYY-MM-DD'] a pattern, or
|
|
147
|
+
* `(date: Date) => string`
|
|
148
|
+
* @param {{utc?: boolean}} [o] draw in UTC rather than the local zone
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function formatDate(value, pattern = ISO_DATE, { utc = false } = {}) {
|
|
152
|
+
if (value == null || value === '') return '';
|
|
153
|
+
const t = parseDateValue(value, { utc });
|
|
154
|
+
if (!Number.isFinite(t)) return String(value);
|
|
155
|
+
const date = new Date(t);
|
|
156
|
+
if (typeof pattern === 'function') return String(pattern(date) ?? '');
|
|
157
|
+
const get = utc
|
|
158
|
+
? {
|
|
159
|
+
y: date.getUTCFullYear(), mo: date.getUTCMonth(), d: date.getUTCDate(),
|
|
160
|
+
wd: date.getUTCDay(), h: date.getUTCHours(), mi: date.getUTCMinutes(),
|
|
161
|
+
s: date.getUTCSeconds(), ms: date.getUTCMilliseconds(),
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
y: date.getFullYear(), mo: date.getMonth(), d: date.getDate(),
|
|
165
|
+
wd: date.getDay(), h: date.getHours(), mi: date.getMinutes(),
|
|
166
|
+
s: date.getSeconds(), ms: date.getMilliseconds(),
|
|
167
|
+
};
|
|
168
|
+
const h12 = get.h % 12 === 0 ? 12 : get.h % 12;
|
|
169
|
+
return String(pattern || ISO_DATE).replace(
|
|
170
|
+
/\[([^\]]*)\]|YYYY|YY|MMMM|MMM|MM|M|dddd|ddd|DD|D|HH|H|hh|h|mm|m|ss|s|SSS|A|a/g,
|
|
171
|
+
(tok, literal) => {
|
|
172
|
+
if (literal !== undefined) return literal;
|
|
173
|
+
switch (tok) {
|
|
174
|
+
case 'YYYY': return pad(get.y, 4);
|
|
175
|
+
case 'YY': return pad(get.y % 100);
|
|
176
|
+
case 'MMMM': return MONTHS[get.mo];
|
|
177
|
+
case 'MMM': return MONTHS[get.mo].slice(0, 3);
|
|
178
|
+
case 'MM': return pad(get.mo + 1);
|
|
179
|
+
case 'M': return String(get.mo + 1);
|
|
180
|
+
case 'dddd': return WEEKDAYS[get.wd];
|
|
181
|
+
case 'ddd': return WEEKDAYS[get.wd].slice(0, 3);
|
|
182
|
+
case 'DD': return pad(get.d);
|
|
183
|
+
case 'D': return String(get.d);
|
|
184
|
+
case 'HH': return pad(get.h);
|
|
185
|
+
case 'H': return String(get.h);
|
|
186
|
+
case 'hh': return pad(h12);
|
|
187
|
+
case 'h': return String(h12);
|
|
188
|
+
case 'mm': return pad(get.mi);
|
|
189
|
+
case 'm': return String(get.mi);
|
|
190
|
+
case 'ss': return pad(get.s);
|
|
191
|
+
case 's': return String(get.s);
|
|
192
|
+
case 'SSS': return pad(get.ms, 3);
|
|
193
|
+
case 'A': return get.h < 12 ? 'AM' : 'PM';
|
|
194
|
+
case 'a': return get.h < 12 ? 'am' : 'pm';
|
|
195
|
+
default: return tok;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The PERIOD an ISO prefix names, as `[start, end)` in epoch ms — `2026` is the
|
|
202
|
+
* year, `2026-10` the month, `2026-10-05` the day, `2026-10-05 14` the hour,
|
|
203
|
+
* `2026-10-05 14:30` the minute — ISO's own two-digit parts, nothing looser. A
|
|
204
|
+
* full ISO instant (with a zone, say) is the one millisecond it names. `null`
|
|
205
|
+
* for anything else: half typed (`2`, `202`, `2026-`, `2026-1`), a date that
|
|
206
|
+
* does not exist (`2026-02-31`), or words (`5 Oct`). That is how a half-typed
|
|
207
|
+
* operand (`>20`) filters nothing yet, and how text with no operator falls
|
|
208
|
+
* through to the drawn words (`matchDateFilter`).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} text
|
|
211
|
+
* @param {{utc?: boolean}} [o]
|
|
212
|
+
* @returns {{start: number, end: number}|null}
|
|
213
|
+
*/
|
|
214
|
+
export function parseDatePeriod(text, { utc = false } = {}) {
|
|
215
|
+
const t = String(text ?? '').trim();
|
|
216
|
+
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:[T ](\d{2})(?::(\d{2})(?::(\d{2}))?)?)?)?)?$/i.exec(t);
|
|
217
|
+
if (m) {
|
|
218
|
+
const parts = [Number(m[1]),
|
|
219
|
+
m[2] ? Number(m[2]) - 1 : 0,
|
|
220
|
+
m[3] ? Number(m[3]) : 1,
|
|
221
|
+
m[4] ? Number(m[4]) : 0,
|
|
222
|
+
m[5] ? Number(m[5]) : 0,
|
|
223
|
+
m[6] ? Number(m[6]) : 0];
|
|
224
|
+
if (parts[1] < 0 || parts[1] > 11 || parts[2] < 1 || parts[2] > daysInMonth(parts[0], parts[1])
|
|
225
|
+
|| parts[3] > 23 || parts[4] > 59 || parts[5] > 59) return null;
|
|
226
|
+
// The least significant part that was GIVEN is the one the period spans.
|
|
227
|
+
const unit = m[6] ? 5 : m[5] ? 4 : m[4] ? 3 : m[3] ? 2 : m[2] ? 1 : 0;
|
|
228
|
+
const next = parts.slice();
|
|
229
|
+
next[unit] += 1;
|
|
230
|
+
return { start: partsToTime(utc, ...parts), end: partsToTime(utc, ...next) };
|
|
231
|
+
}
|
|
232
|
+
// Not a prefix: a full ISO instant or nothing (`parseDateValue` reads no
|
|
233
|
+
// other text).
|
|
234
|
+
const instant = parseDateValue(t, { utc });
|
|
235
|
+
return Number.isFinite(instant) ? { start: instant, end: instant + 1 } : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const DURATION_UNITS = {
|
|
239
|
+
ms: 1, msec: 1, msecs: 1, millisecond: 1, milliseconds: 1,
|
|
240
|
+
s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,
|
|
241
|
+
m: 60000, min: 60000, mins: 60000, minute: 60000, minutes: 60000,
|
|
242
|
+
h: 3600000, hr: 3600000, hrs: 3600000, hour: 3600000, hours: 3600000,
|
|
243
|
+
d: 86400000, day: 86400000, days: 86400000,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Milliseconds for a duration: a number (taken as ms), or text — `850 ms`,
|
|
248
|
+
* `3.2 s`, `2m 5s`, `1h 30m`, `2 days`, or a clock `1:02:05` (`h:mm:ss`, with
|
|
249
|
+
* an optional `d.` in front). `NaN` when it is none of those.
|
|
250
|
+
*
|
|
251
|
+
* @param {number|string|null|undefined} value
|
|
252
|
+
* @returns {number}
|
|
253
|
+
*/
|
|
254
|
+
export function parseDuration(value) {
|
|
255
|
+
if (value == null || value === '') return NaN;
|
|
256
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN;
|
|
257
|
+
let text = String(value).trim().toLowerCase();
|
|
258
|
+
if (!text) return NaN;
|
|
259
|
+
let sign = 1;
|
|
260
|
+
if (text[0] === '-') { sign = -1; text = text.slice(1).trim(); }
|
|
261
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return sign * Number(text);
|
|
262
|
+
const clock = /^(?:(\d+)\.)?(\d+):(\d{1,2})(?::(\d{1,2}(?:\.\d+)?))?$/.exec(text);
|
|
263
|
+
if (clock) {
|
|
264
|
+
const [, d, a, b, c] = clock;
|
|
265
|
+
// h:mm:ss, or h:mm when there is no third part.
|
|
266
|
+
const ms = (Number(d || 0) * 86400 + Number(a) * 3600 + Number(b) * 60
|
|
267
|
+
+ Number(c || 0)) * 1000;
|
|
268
|
+
return sign * ms;
|
|
269
|
+
}
|
|
270
|
+
const re = /(\d+(?:\.\d+)?)\s*([a-z]+)\s*/g;
|
|
271
|
+
let total = 0;
|
|
272
|
+
let consumed = 0;
|
|
273
|
+
let m;
|
|
274
|
+
while ((m = re.exec(text)) !== null) {
|
|
275
|
+
if (m.index !== consumed) return NaN;
|
|
276
|
+
const unit = DURATION_UNITS[m[2]];
|
|
277
|
+
if (unit === undefined) return NaN;
|
|
278
|
+
total += Number(m[1]) * unit;
|
|
279
|
+
consumed = re.lastIndex;
|
|
280
|
+
}
|
|
281
|
+
return consumed === text.length && consumed > 0 ? sign * total : NaN;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Draw a duration in milliseconds.
|
|
286
|
+
*
|
|
287
|
+
* 'auto' (default) `850 ms`, `3.2 s`, `2m 5s`, `1h 2m`, `3d 4h` — the two
|
|
288
|
+
* largest units that matter, which is what a list of run times or
|
|
289
|
+
* query times wants
|
|
290
|
+
* 'clock' `0:00:03`, `1:02:05`, `2.04:00:00` (`d.h:mm:ss`)
|
|
291
|
+
* a function `(ms) => string`
|
|
292
|
+
*
|
|
293
|
+
* Text that is not a duration is drawn as it came, like `formatDate`.
|
|
294
|
+
*
|
|
295
|
+
* @param {number|string|null|undefined} value
|
|
296
|
+
* @param {'auto'|'clock'|Function} [pattern='auto']
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function formatDuration(value, pattern = 'auto') {
|
|
300
|
+
if (value == null || value === '') return '';
|
|
301
|
+
const ms = parseDuration(value);
|
|
302
|
+
if (!Number.isFinite(ms)) return String(value);
|
|
303
|
+
if (typeof pattern === 'function') return String(pattern(ms) ?? '');
|
|
304
|
+
const neg = ms < 0;
|
|
305
|
+
const a = Math.abs(ms);
|
|
306
|
+
let out;
|
|
307
|
+
if (pattern === 'clock') {
|
|
308
|
+
const total = Math.round(a / 1000);
|
|
309
|
+
const d = Math.floor(total / 86400);
|
|
310
|
+
const h = Math.floor((total % 86400) / 3600);
|
|
311
|
+
const mi = Math.floor((total % 3600) / 60);
|
|
312
|
+
const s = total % 60;
|
|
313
|
+
out = `${d ? `${d}.${pad(h)}` : h}:${pad(mi)}:${pad(s)}`;
|
|
314
|
+
} else if (a < 1000) {
|
|
315
|
+
out = `${Math.round(a)} ms`;
|
|
316
|
+
} else if (a < 59950) {
|
|
317
|
+
out = `${(a / 1000).toFixed(1)} s`;
|
|
318
|
+
} else if (a < 3600000) {
|
|
319
|
+
let mi = Math.floor(a / 60000);
|
|
320
|
+
let s = Math.round((a % 60000) / 1000);
|
|
321
|
+
if (s === 60) { mi += 1; s = 0; }
|
|
322
|
+
out = `${mi}m ${s}s`;
|
|
323
|
+
} else if (a < 86400000) {
|
|
324
|
+
let h = Math.floor(a / 3600000);
|
|
325
|
+
let mi = Math.round((a % 3600000) / 60000);
|
|
326
|
+
if (mi === 60) { h += 1; mi = 0; }
|
|
327
|
+
out = `${h}h ${mi}m`;
|
|
328
|
+
} else {
|
|
329
|
+
let d = Math.floor(a / 86400000);
|
|
330
|
+
let h = Math.round((a % 86400000) / 3600000);
|
|
331
|
+
if (h === 24) { d += 1; h = 0; }
|
|
332
|
+
out = `${d}d ${h}h`;
|
|
333
|
+
}
|
|
334
|
+
return neg ? `-${out}` : out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Does a time `t` (epoch ms) pass a date filter's text?
|
|
339
|
+
*
|
|
340
|
+
* >2026-10 after October 2026 >=2026-10 from the 1st of October
|
|
341
|
+
* <2026-10-05 before the 5th <=2026-10-05 up to the end of the 5th
|
|
342
|
+
* =2026-10 in October !=2026-10 not in October
|
|
343
|
+
* 2026-01..2026-03 January to the end of March
|
|
344
|
+
* 2026-10 (no operator) in October
|
|
345
|
+
*
|
|
346
|
+
* An operand that is not a date yet (`>20`, half typed) filters nothing. Text
|
|
347
|
+
* with no operator that names no period is matched against the cell's DRAWN
|
|
348
|
+
* words, so `Oct` finds October under an `MMM` pattern, `5 Oct` the 5th under
|
|
349
|
+
* `D MMM YYYY`, and `2026-1` (on the way to `2026-10`) whatever is drawn with
|
|
350
|
+
* it under the ISO default.
|
|
351
|
+
*
|
|
352
|
+
* @param {number} t the cell's time, or NaN
|
|
353
|
+
* @param {string} filterText
|
|
354
|
+
* @param {string} shown the cell as drawn
|
|
355
|
+
* @param {{utc?: boolean}} [o]
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
export function matchDateFilter(t, filterText, shown, { utc = false } = {}) {
|
|
359
|
+
const text = String(filterText ?? '').trim();
|
|
360
|
+
if (!text) return true;
|
|
361
|
+
const range = /^(.+?)\s*\.\.\s*(.+)$/.exec(text);
|
|
362
|
+
if (range) {
|
|
363
|
+
const lo = parseDatePeriod(range[1], { utc });
|
|
364
|
+
const hi = parseDatePeriod(range[2], { utc });
|
|
365
|
+
if (!lo || !hi) return true;
|
|
366
|
+
return Number.isFinite(t) && t >= lo.start && t < hi.end;
|
|
367
|
+
}
|
|
368
|
+
const op = /^(>=|<=|!=|>|<|=)\s*(.+)$/.exec(text);
|
|
369
|
+
if (op) {
|
|
370
|
+
const p = parseDatePeriod(op[2], { utc });
|
|
371
|
+
if (!p) return true;
|
|
372
|
+
if (!Number.isFinite(t)) return false;
|
|
373
|
+
switch (op[1]) {
|
|
374
|
+
case '>': return t >= p.end;
|
|
375
|
+
case '>=': return t >= p.start;
|
|
376
|
+
case '<': return t < p.start;
|
|
377
|
+
case '<=': return t < p.end;
|
|
378
|
+
case '=': return t >= p.start && t < p.end;
|
|
379
|
+
case '!=': return !(t >= p.start && t < p.end);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const p = parseDatePeriod(text, { utc });
|
|
383
|
+
if (p) return Number.isFinite(t) && t >= p.start && t < p.end;
|
|
384
|
+
return String(shown ?? '').toLowerCase().includes(text.toLowerCase());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Does a duration `ms` pass a duration filter's text? The operators are the
|
|
389
|
+
* numeric ones, and an operand carries its unit (`>1s`, `<500ms`, `2m..5m`); a
|
|
390
|
+
* bare number is milliseconds. Text with no operator is matched against the
|
|
391
|
+
* cell's drawn words.
|
|
392
|
+
*
|
|
393
|
+
* @param {number} ms
|
|
394
|
+
* @param {string} filterText
|
|
395
|
+
* @param {string} shown
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
export function matchDurationFilter(ms, filterText, shown) {
|
|
399
|
+
const text = String(filterText ?? '').trim();
|
|
400
|
+
if (!text) return true;
|
|
401
|
+
const range = /^(.+?)\s*\.\.\s*(.+)$/.exec(text);
|
|
402
|
+
if (range) {
|
|
403
|
+
const lo = parseDuration(range[1]);
|
|
404
|
+
const hi = parseDuration(range[2]);
|
|
405
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return true;
|
|
406
|
+
return Number.isFinite(ms) && ms >= lo && ms <= hi;
|
|
407
|
+
}
|
|
408
|
+
const op = /^(>=|<=|!=|>|<|=)\s*(.+)$/.exec(text);
|
|
409
|
+
if (op) {
|
|
410
|
+
const target = parseDuration(op[2]);
|
|
411
|
+
if (!Number.isFinite(target)) return true;
|
|
412
|
+
if (!Number.isFinite(ms)) return false;
|
|
413
|
+
switch (op[1]) {
|
|
414
|
+
case '>': return ms > target;
|
|
415
|
+
case '>=': return ms >= target;
|
|
416
|
+
case '<': return ms < target;
|
|
417
|
+
case '<=': return ms <= target;
|
|
418
|
+
case '=': return ms === target;
|
|
419
|
+
case '!=': return ms !== target;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return String(shown ?? '').toLowerCase().includes(text.toLowerCase());
|
|
423
|
+
}
|