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.
@@ -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
+ }