@xeplr/ui-utils 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-utils",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "UI utility components: file upload with progress, snackbar, static data (countries/states)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,5 +28,8 @@
28
28
  "peerDependencies": {
29
29
  "react": "^18.0.0 || ^19.0.0",
30
30
  "@xeplr/ui-table": "^1.0.0"
31
+ },
32
+ "scripts": {
33
+ "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
31
34
  }
32
35
  }
@@ -1,12 +1,23 @@
1
- // Imperative, self-mounting confirm dialog — same shape as ../snackbar/snackbar.js
1
+ // Imperative, self-mounting modal dialogs — same shape as ../snackbar/snackbar.js
2
2
  // (own DOM node, inline styles, no provider/mount point needed, no CSS import).
3
- // Unlike the snackbar it's modal and returns a Promise, since a caller needs
3
+ // Unlike the snackbar these are modal and return a Promise, since a caller needs
4
4
  // the answer before proceeding (e.g. "enter this workspace?").
5
+ //
6
+ // Two shapes, ONE dialog surface:
7
+ // raiseConfirm — yes/no. The common case, and unchanged.
8
+ // raiseChoice — any number of answers, because some questions genuinely have
9
+ // three. "You have unsaved changes and you are leaving" is the
10
+ // standing example: save, discard, or come back — and folding
11
+ // that into a boolean forces the caller to either drop one of
12
+ // the three or ask twice.
13
+ //
14
+ // They share _root and _cancelPending deliberately: two dialog surfaces could
15
+ // each hold one open at once, and the second would silently cover the first.
5
16
 
6
17
  var _root = null;
7
- // A second raiseConfirm() before the first resolves would otherwise stack a
8
- // second overlay into the same _root and add a second document keydown
9
- // listener — cancel whatever's still open before mounting a new one.
18
+ // A second dialog before the first resolves would otherwise stack a second
19
+ // overlay into the same _root and add a second document keydown listener —
20
+ // cancel whatever's still open before mounting a new one.
10
21
  var _cancelPending = null;
11
22
 
12
23
  function ensureRoot() {
@@ -18,20 +29,18 @@ function ensureRoot() {
18
29
  }
19
30
 
20
31
  /**
21
- * @param {string} message
22
- * @param {object} [options]
23
- * @param {string} [options.title='Are you sure?']
24
- * @param {string} [options.confirmLabel='Confirm']
25
- * @param {string} [options.cancelLabel='Cancel']
26
- * @param {boolean} [options.danger=false] - style the confirm button as destructive
27
- * @returns {Promise<boolean>} resolves true (confirmed) or false (cancelled/dismissed)
32
+ * The shell both shapes are built from.
33
+ *
34
+ * @param {object} spec
35
+ * @param {string} spec.title
36
+ * @param {string} spec.message
37
+ * @param {Array} spec.buttons - [{ label, value, style: 'primary'|'danger'|'plain', isDefault }]
38
+ * rendered left to right; the LAST one is focused.
39
+ * @param {*} spec.dismissValue - what Escape, the overlay and a superseding
40
+ * dialog all resolve to.
41
+ * @returns {Promise<*>} the chosen button's `value`
28
42
  */
29
- export function raiseConfirm(message, options) {
30
- options = options || {};
31
- var title = options.title || 'Are you sure?';
32
- var confirmLabel = options.confirmLabel || 'Confirm';
33
- var cancelLabel = options.cancelLabel || 'Cancel';
34
-
43
+ function openDialog(spec) {
35
44
  if (_cancelPending) _cancelPending();
36
45
 
37
46
  return new Promise(function(resolve) {
@@ -40,16 +49,23 @@ export function raiseConfirm(message, options) {
40
49
  function cleanup(result) {
41
50
  document.removeEventListener('keydown', onKeyDown);
42
51
  root.innerHTML = '';
43
- if (_cancelPending === cleanupAsCancel) _cancelPending = null;
52
+ if (_cancelPending === cleanupAsDismiss) _cancelPending = null;
44
53
  resolve(result);
45
54
  }
46
55
 
47
- function cleanupAsCancel() { cleanup(false); }
48
- _cancelPending = cleanupAsCancel;
56
+ function cleanupAsDismiss() { cleanup(spec.dismissValue); }
57
+ _cancelPending = cleanupAsDismiss;
49
58
 
50
59
  function onKeyDown(e) {
51
- if (e.key === 'Escape') cleanup(false);
52
- if (e.key === 'Enter') cleanup(true);
60
+ if (e.key === 'Escape') cleanup(spec.dismissValue);
61
+ if (e.key === 'Enter') {
62
+ // Enter takes the DEFAULT, and only if one was named. With three
63
+ // answers there is no obvious one to guess at, and guessing would
64
+ // commit somebody's work — or throw it away — on a stray keypress.
65
+ for (var i = 0; i < spec.buttons.length; i++) {
66
+ if (spec.buttons[i].isDefault) return cleanup(spec.buttons[i].value);
67
+ }
68
+ }
53
69
  }
54
70
 
55
71
  // var(--xeplr-*, fallback) — picks up @xeplr/ui-account's theme tokens
@@ -58,42 +74,45 @@ export function raiseConfirm(message, options) {
58
74
  var overlay = document.createElement('div');
59
75
  overlay.style.cssText = 'position:fixed;inset:0;background:var(--xeplr-bg-overlay,rgba(0,0,0,0.55));z-index:100000;'
60
76
  + 'display:flex;align-items:center;justify-content:center;';
61
- overlay.onclick = function(e) { if (e.target === overlay) cleanup(false); };
77
+ overlay.onclick = function(e) { if (e.target === overlay) cleanup(spec.dismissValue); };
62
78
 
63
79
  var box = document.createElement('div');
64
80
  box.style.cssText = 'background:var(--xeplr-bg-tertiary,#1a1a1a);color:var(--xeplr-text-primary,#e0e0e0);'
65
81
  + 'border:1px solid var(--xeplr-border-primary,#333);border-radius:10px;'
66
- + 'padding:20px;max-width:380px;width:calc(100% - 40px);font:14px/1.5 system-ui,sans-serif;'
82
+ + 'padding:20px;max-width:420px;width:calc(100% - 40px);font:14px/1.5 system-ui,sans-serif;'
67
83
  + 'box-shadow:var(--xeplr-shadow-lg,0 12px 32px rgba(0,0,0,0.4));';
68
84
 
69
85
  var titleEl = document.createElement('div');
70
86
  titleEl.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:8px;';
71
- titleEl.textContent = title;
87
+ titleEl.textContent = spec.title;
72
88
 
73
89
  var messageEl = document.createElement('div');
74
90
  messageEl.style.cssText = 'color:var(--xeplr-text-secondary,#bbb);margin-bottom:18px;';
75
- messageEl.textContent = message;
91
+ messageEl.textContent = spec.message;
76
92
 
77
93
  var actions = document.createElement('div');
78
- actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;';
79
-
80
- var cancelBtn = document.createElement('button');
81
- cancelBtn.type = 'button';
82
- cancelBtn.textContent = cancelLabel;
83
- cancelBtn.style.cssText = 'padding:8px 14px;border:1px solid var(--xeplr-border-primary,#444);border-radius:6px;'
84
- + 'background:var(--xeplr-bg-secondary,#232323);color:var(--xeplr-text-primary,#e0e0e0);font-size:13px;cursor:pointer;';
85
- cancelBtn.onclick = function() { cleanup(false); };
86
-
87
- var confirmBtn = document.createElement('button');
88
- confirmBtn.type = 'button';
89
- confirmBtn.textContent = confirmLabel;
90
- var confirmBg = options.danger ? 'var(--xeplr-danger,#dc2626)' : 'var(--xeplr-accent,#646cff)';
91
- confirmBtn.style.cssText = 'padding:8px 14px;border:none;border-radius:6px;'
92
- + 'background:' + confirmBg + ';color:var(--xeplr-accent-text,#fff);font-size:13px;font-weight:600;cursor:pointer;';
93
- confirmBtn.onclick = function() { cleanup(true); };
94
-
95
- actions.appendChild(cancelBtn);
96
- actions.appendChild(confirmBtn);
94
+ // Wraps, because three labels that each say what they DO ("Leave without
95
+ // saving") are longer than "OK" and must not run off a narrow dialog.
96
+ actions.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;';
97
+
98
+ var lastBtn = null;
99
+ spec.buttons.forEach(function(def) {
100
+ var btn = document.createElement('button');
101
+ btn.type = 'button';
102
+ btn.textContent = def.label;
103
+ if (def.style === 'primary' || def.style === 'danger') {
104
+ var bg = def.style === 'danger' ? 'var(--xeplr-danger,#dc2626)' : 'var(--xeplr-accent,#646cff)';
105
+ btn.style.cssText = 'padding:8px 14px;border:none;border-radius:6px;'
106
+ + 'background:' + bg + ';color:var(--xeplr-accent-text,#fff);font-size:13px;font-weight:600;cursor:pointer;';
107
+ } else {
108
+ btn.style.cssText = 'padding:8px 14px;border:1px solid var(--xeplr-border-primary,#444);border-radius:6px;'
109
+ + 'background:var(--xeplr-bg-secondary,#232323);color:var(--xeplr-text-primary,#e0e0e0);font-size:13px;cursor:pointer;';
110
+ }
111
+ btn.onclick = function() { cleanup(def.value); };
112
+ actions.appendChild(btn);
113
+ lastBtn = btn;
114
+ });
115
+
97
116
  box.appendChild(titleEl);
98
117
  box.appendChild(messageEl);
99
118
  box.appendChild(actions);
@@ -101,6 +120,56 @@ export function raiseConfirm(message, options) {
101
120
  root.appendChild(overlay);
102
121
 
103
122
  document.addEventListener('keydown', onKeyDown);
104
- confirmBtn.focus();
123
+ if (lastBtn) lastBtn.focus();
124
+ });
125
+ }
126
+
127
+ /**
128
+ * @param {string} message
129
+ * @param {object} [options]
130
+ * @param {string} [options.title='Are you sure?']
131
+ * @param {string} [options.confirmLabel='Confirm']
132
+ * @param {string} [options.cancelLabel='Cancel']
133
+ * @param {boolean} [options.danger=false] - style the confirm button as destructive
134
+ * @returns {Promise<boolean>} resolves true (confirmed) or false (cancelled/dismissed)
135
+ */
136
+ export function raiseConfirm(message, options) {
137
+ options = options || {};
138
+ return openDialog({
139
+ title: options.title || 'Are you sure?',
140
+ message: message,
141
+ dismissValue: false,
142
+ buttons: [
143
+ { label: options.cancelLabel || 'Cancel', value: false, style: 'plain' },
144
+ { label: options.confirmLabel || 'Confirm', value: true, isDefault: true,
145
+ style: options.danger ? 'danger' : 'primary' }
146
+ ]
147
+ });
148
+ }
149
+
150
+ /**
151
+ * A question with more than two answers.
152
+ *
153
+ * @param {string} message
154
+ * @param {object} options
155
+ * @param {string} [options.title='Are you sure?']
156
+ * @param {Array} options.choices - [{ value, label, style?, isDefault? }], left to
157
+ * right; the last is focused, and `style` is 'primary' | 'danger' |
158
+ * omitted for plain. Put the answer somebody most likely wants last.
159
+ * @param {*} [options.dismissValue=null] - Escape / clicking away / being
160
+ * superseded. Default null, so "they closed the dialog" is
161
+ * distinguishable from every deliberate answer.
162
+ * @returns {Promise<*>} the chosen `value`
163
+ */
164
+ export function raiseChoice(message, options) {
165
+ options = options || {};
166
+ var choices = options.choices || [];
167
+ return openDialog({
168
+ title: options.title || 'Are you sure?',
169
+ message: message,
170
+ dismissValue: options.dismissValue === undefined ? null : options.dismissValue,
171
+ buttons: choices.map(function(c) {
172
+ return { label: c.label, value: c.value, style: c.style || 'plain', isDefault: c.isDefault };
173
+ })
105
174
  });
106
175
  }
@@ -1,11 +1,17 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
 
3
3
  /**
4
- * Validates that required elements exist in the rendered design.
5
- * Throws a visible error if any required element is missing.
4
+ * Validates that required elements exist in the rendered design, and reports
5
+ * loudly when they don't console.error plus a data-xeplr-design-invalid
6
+ * marker on the container.
7
+ *
8
+ * A rule matches by id, role or selector. `anyOf` takes a list of selectors
9
+ * and passes when ANY one is present — which is what a design with tabs or
10
+ * steps needs, since only the current one is mounted and demanding all of
11
+ * them at once can never pass.
6
12
  *
7
13
  * @param {string} componentName - Name of the page (for error messages)
8
- * @param {Array<{id?: string, role?: string, selector?: string, label: string}>} requiredElements
14
+ * @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
9
15
  */
10
16
  export function useDesignValidator(componentName, requiredElements) {
11
17
  var containerRef = useRef(null);
@@ -22,20 +28,33 @@ export function useDesignValidator(componentName, requiredElements) {
22
28
  found = !!containerRef.current.querySelector('#' + rule.id);
23
29
  } else if (rule.role) {
24
30
  found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
31
+ } else if (rule.anyOf) {
32
+ for (var j = 0; j < rule.anyOf.length && !found; j++) {
33
+ found = !!containerRef.current.querySelector(rule.anyOf[j]);
34
+ }
25
35
  } else if (rule.selector) {
26
36
  found = !!containerRef.current.querySelector(rule.selector);
27
37
  }
28
38
 
29
39
  if (!found) {
30
- missing.push(rule.label + (rule.id ? ' (id="' + rule.id + '")' : '') + (rule.selector ? ' (' + rule.selector + ')' : ''));
40
+ var where = rule.id ? ' (id="' + rule.id + '")'
41
+ : rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
42
+ : rule.selector ? ' (' + rule.selector + ')' : '';
43
+ missing.push(rule.label + where);
31
44
  }
32
45
  }
33
46
 
34
47
  if (missing.length > 0) {
35
- throw new Error(
36
- '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
37
- missing.map(function(m) { return ' - ' + m; }).join('\n')
38
- );
48
+ // Reported, not thrown. Throwing from an effect gives React nothing to
49
+ // catch, so a design rule a development-time check took the whole
50
+ // application down with it. The container is marked so the failure is
51
+ // visible on the page as well as in the console, which is what "fail
52
+ // loudly" was ever meant to buy.
53
+ var message = '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
54
+ missing.map(function(m) { return ' - ' + m; }).join('\n');
55
+ console.error(message);
56
+ containerRef.current.setAttribute('data-xeplr-design-invalid', componentName);
57
+ containerRef.current.setAttribute('title', message);
39
58
  }
40
59
  }, []);
41
60
 
@@ -0,0 +1,273 @@
1
+ // DateRange — model. A date SELECTION, and how it becomes a real from/to
2
+ // range. Pure: no React, no DOM.
3
+ //
4
+ // One shape covers every way a person names a span of time, which is the
5
+ // point. A default set once, a preset that keeps moving, and a range typed by
6
+ // hand are all the same value, so an app can fall back between them without
7
+ // three different mechanisms.
8
+ //
9
+ // { kind: 'quick', preset: 'current_month' }
10
+ // { kind: 'range', from: '2022-01-01', to: '2022-03-31' }
11
+ // { kind: 'single', value: '2022-05' } // a day, a month, or a year
12
+ // null // not set — inherit
13
+ //
14
+ // EVERYTHING RESOLVES TO A RANGE, including a single date. "May 2022" is the
15
+ // range 1–31 May; "2022" is the whole year; "1/1/2022" is that one day. A
16
+ // consumer therefore always gets {from, to} and never has to ask which
17
+ // comparison was meant — the selection already says.
18
+
19
+ // April. Overridable per call — a financial year starts in a different month
20
+ // in most of the world, and hardcoding one is how a report quietly reports
21
+ // the wrong year.
22
+ var DEFAULT_FISCAL_START_MONTH = 4
23
+
24
+ function pad(n) { return String(n).padStart(2, '0') }
25
+ function iso(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}` }
26
+ function lastDay(y, m) { return new Date(y, m + 1, 0).getDate() }
27
+
28
+ /**
29
+ * The presets people actually ask for. Each resolves against `now`, so a
30
+ * report saved with one stays current instead of freezing on the day it was
31
+ * built — the whole reason to prefer a preset over a literal date.
32
+ */
33
+ export const QUICK_PRESETS = [
34
+ { key: 'today', label: 'Today' },
35
+ { key: 'yesterday', label: 'Yesterday' },
36
+ { key: 'dby', label: 'Day before yesterday' },
37
+ { key: 'last_7_days', label: 'Last 7 days' },
38
+ { key: 'last_30_days', label: 'Last 30 days' },
39
+ { key: 'current_month', label: 'Current month' },
40
+ { key: 'current_mtd', label: 'Current month-to-date' },
41
+ { key: 'last_month', label: 'Last month' },
42
+ { key: 'current_quarter', label: 'Current quarter' },
43
+ { key: 'current_qtd', label: 'Current quarter-to-date' },
44
+ { key: 'last_quarter', label: 'Last quarter' },
45
+ { key: 'current_calendar_year', label: 'Current calendar year' },
46
+ { key: 'current_ytd', label: 'Current year-to-date' },
47
+ { key: 'last_calendar_year', label: 'Last calendar year' },
48
+ { key: 'current_financial_year', label: 'Current financial year' },
49
+ { key: 'current_fytd', label: 'Current financial year-to-date' },
50
+ { key: 'last_financial_year', label: 'Last financial year' }
51
+ ]
52
+
53
+ const PRESET_LABEL = new Map(QUICK_PRESETS.map((p) => [p.key, p.label]))
54
+
55
+ const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june',
56
+ 'july', 'august', 'september', 'october', 'november', 'december']
57
+
58
+ /**
59
+ * Parses the free text a Single selection accepts: a full date, a month, or a
60
+ * year. Returns a {from, to} range, since a month or a year IS a range.
61
+ *
62
+ * Day-first for the ambiguous slash form (1/2/2022 is 1 February), matching
63
+ * where this is used rather than the American reading.
64
+ */
65
+ export function parseSingleDate(text) {
66
+ const raw = String(text || '').trim()
67
+ if (!raw) return null
68
+
69
+ // 2022
70
+ let m = /^(\d{4})$/.exec(raw)
71
+ if (m) {
72
+ const y = Number(m[1])
73
+ return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
74
+ }
75
+
76
+ // 2022-05 or 2022/05
77
+ m = /^(\d{4})[-/](\d{1,2})$/.exec(raw)
78
+ if (m) {
79
+ const y = Number(m[1]); const mo = Number(m[2]) - 1
80
+ if (mo < 0 || mo > 11) return null
81
+ return { from: iso(y, mo, 1), to: iso(y, mo, lastDay(y, mo)) }
82
+ }
83
+
84
+ // 2022-05-17
85
+ m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(raw)
86
+ if (m) {
87
+ const y = Number(m[1]); const mo = Number(m[2]) - 1; const d = Number(m[3])
88
+ if (mo < 0 || mo > 11) return null
89
+ return { from: iso(y, mo, d), to: iso(y, mo, d) }
90
+ }
91
+
92
+ // May 2022 / may 2022
93
+ m = /^([A-Za-z]+)\s+(\d{4})$/.exec(raw)
94
+ if (m) {
95
+ const idx = MONTHS.findIndex((name) => name.startsWith(m[1].toLowerCase()))
96
+ if (idx === -1) return null
97
+ const y = Number(m[2])
98
+ return { from: iso(y, idx, 1), to: iso(y, idx, lastDay(y, idx)) }
99
+ }
100
+
101
+ // 17/5/2022 — day first.
102
+ m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/.exec(raw)
103
+ if (m) {
104
+ const d = Number(m[1]); const mo = Number(m[2]) - 1; const y = Number(m[3])
105
+ if (mo < 0 || mo > 11) return null
106
+ return { from: iso(y, mo, d), to: iso(y, mo, d) }
107
+ }
108
+
109
+ return null
110
+ }
111
+
112
+ function shift(now, days) {
113
+ const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days)
114
+ return iso(d.getFullYear(), d.getMonth(), d.getDate())
115
+ }
116
+
117
+ function monthRange(y, m) {
118
+ return { from: iso(y, m, 1), to: iso(y, m, lastDay(y, m)) }
119
+ }
120
+
121
+ /**
122
+ * What somebody might mean, as they type — months and years, never days.
123
+ *
124
+ * A day is faster to finish typing than to pick out of a list, and there are
125
+ * thirty of them. A MONTH is the opposite: "j" is three different months, and
126
+ * each of those is a different year depending on what you were just looking
127
+ * at. That ambiguity is the whole reason to suggest anything.
128
+ *
129
+ * ANCHORED to the year already in play — if the filter currently says
130
+ * Feb 2025, typing "j" offers Jan/Jun/Jul 2025 first, because somebody
131
+ * comparing months is almost always comparing them within a year. Other years
132
+ * follow, so the less common case is one click rather than more typing.
133
+ *
134
+ * @param {string} text what has been typed so far
135
+ * @param {number} anchor the year to offer first
136
+ * @param {number} years how many additional years to offer per month
137
+ */
138
+ export function singleDateSuggestions(text, anchor, years) {
139
+ const q = String(text || '').trim().toLowerCase()
140
+ const year = Number(anchor) || new Date().getFullYear()
141
+ const depth = years == null ? 2 : years
142
+ if (!q) return []
143
+
144
+ // A year on its own — "202" is a prefix of several, and a whole year is a
145
+ // legitimate answer in its own right.
146
+ if (/^\d{1,4}$/.test(q)) {
147
+ const out = []
148
+ for (let y = year + 1; y >= year - 6; y--) {
149
+ if (String(y).startsWith(q)) out.push({ value: String(y), label: String(y), note: 'the whole year' })
150
+ }
151
+ return out.slice(0, 8)
152
+ }
153
+
154
+ // "jan 20", "june 2024" — the month is settled, so only years are open.
155
+ const withYear = /^([a-z]+)\s+(\d{1,4})$/.exec(q)
156
+ const monthQuery = withYear ? withYear[1] : q
157
+ const yearQuery = withYear ? withYear[2] : null
158
+ if (!/^[a-z]+$/.test(monthQuery)) return []
159
+
160
+ const hits = []
161
+ MONTHS.forEach((name, i) => {
162
+ if (!name.startsWith(monthQuery)) return
163
+ hits.push({ name: name.charAt(0).toUpperCase() + name.slice(1), index: i })
164
+ })
165
+ if (!hits.length) return []
166
+
167
+ // The anchor year first for every match, THEN the other years — so a list
168
+ // of three months reads Jan/Jun/Jul 2025 before it reads Jan 2024, rather
169
+ // than burying two of this year's months under last year's January.
170
+ const out = []
171
+ const push = (m, y) => {
172
+ if (yearQuery && !String(y).startsWith(yearQuery)) return
173
+ out.push({ value: m.name + ' ' + y, label: m.name + ' ' + y, note: null })
174
+ }
175
+ hits.forEach((m) => push(m, year))
176
+ for (let d = 1; d <= depth; d++) {
177
+ hits.forEach((m) => push(m, year - d))
178
+ }
179
+ hits.forEach((m) => push(m, year + 1))
180
+ return out.slice(0, 10)
181
+ }
182
+
183
+ export function resolveQuickPreset(preset, now, options) {
184
+ const fiscalStart = ((options && options.fiscalStartMonth) || DEFAULT_FISCAL_START_MONTH) - 1
185
+ const y = now.getFullYear()
186
+ const m = now.getMonth()
187
+ const today = iso(y, m, now.getDate())
188
+ const q = Math.floor(m / 3)
189
+ // The fiscal year containing `now` began this calendar year only if we're
190
+ // already past its start month — same rule as shared/params.js.
191
+ const fy = m >= fiscalStart ? y : y - 1
192
+
193
+ switch (preset) {
194
+ case 'today': return { from: today, to: today }
195
+ case 'yesterday': return { from: shift(now, 1), to: shift(now, 1) }
196
+ case 'dby': return { from: shift(now, 2), to: shift(now, 2) }
197
+ case 'last_7_days': return { from: shift(now, 6), to: today }
198
+ case 'last_30_days': return { from: shift(now, 29), to: today }
199
+
200
+ case 'current_month': return monthRange(y, m)
201
+ case 'current_mtd': return { from: iso(y, m, 1), to: today }
202
+ case 'last_month': return m === 0 ? monthRange(y - 1, 11) : monthRange(y, m - 1)
203
+
204
+ case 'current_quarter': return { from: iso(y, q * 3, 1), to: iso(y, q * 3 + 2, lastDay(y, q * 3 + 2)) }
205
+ case 'current_qtd': return { from: iso(y, q * 3, 1), to: today }
206
+ case 'last_quarter': {
207
+ const ly = q === 0 ? y - 1 : y
208
+ const lq = q === 0 ? 3 : q - 1
209
+ return { from: iso(ly, lq * 3, 1), to: iso(ly, lq * 3 + 2, lastDay(ly, lq * 3 + 2)) }
210
+ }
211
+
212
+ case 'current_calendar_year': return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
213
+ case 'current_ytd': return { from: iso(y, 0, 1), to: today }
214
+ case 'last_calendar_year': return { from: iso(y - 1, 0, 1), to: iso(y - 1, 11, 31) }
215
+
216
+ case 'current_financial_year': {
217
+ const end = new Date(fy + 1, fiscalStart, 0)
218
+ return { from: iso(fy, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
219
+ }
220
+ case 'current_fytd': return { from: iso(fy, fiscalStart, 1), to: today }
221
+ case 'last_financial_year': {
222
+ const end = new Date(fy, fiscalStart, 0)
223
+ return { from: iso(fy - 1, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
224
+ }
225
+
226
+ default: return null
227
+ }
228
+ }
229
+
230
+ /**
231
+ * A selection -> { from, to }, or null when it resolves to nothing.
232
+ *
233
+ * A null result is "no constraint", never "match nothing" — an unresolvable
234
+ * selection must widen rather than silently empty the report.
235
+ */
236
+ export function resolveDateValue(value, now, options) {
237
+ if (!value) return null
238
+ if (value.kind === 'quick') return resolveQuickPreset(value.preset, now || new Date(), options)
239
+ if (value.kind === 'single') return parseSingleDate(value.value)
240
+ if (value.kind === 'range') {
241
+ const from = value.from || null
242
+ const to = value.to || null
243
+ if (!from && !to) return null
244
+ return { from, to }
245
+ }
246
+ return null
247
+ }
248
+
249
+ /** What the chip and the panel read. */
250
+ export function dateValueLabel(value) {
251
+ if (!value) return null
252
+ if (value.kind === 'quick') return PRESET_LABEL.get(value.preset) || value.preset
253
+ if (value.kind === 'single') return value.value || null
254
+ if (value.kind === 'range') {
255
+ if (value.from && value.to) return `${value.from} → ${value.to}`
256
+ if (value.from) return `From ${value.from}`
257
+ if (value.to) return `Until ${value.to}`
258
+ return null
259
+ }
260
+ return null
261
+ }
262
+
263
+ /**
264
+ * True when a selection would actually constrain anything.
265
+ *
266
+ * Defined as "resolves to a range", NOT "has something typed in it". A Single
267
+ * holding unparseable text has a label but no range — treating that as set
268
+ * would let it pass a "do we have a default?" check and then silently apply no
269
+ * filter at all, which is the worst of both.
270
+ */
271
+ export function dateValueIsSet(value, now) {
272
+ return Boolean(resolveDateValue(value, now || new Date()))
273
+ }