@xeplr/ui-utils 1.0.0 → 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.
Files changed (68) hide show
  1. package/package.json +25 -6
  2. package/src/confirm/confirm.js +175 -0
  3. package/src/dataImport/dataImport.js +32 -0
  4. package/src/dataImport/designs/DataImportSample.jsx +71 -0
  5. package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
  6. package/src/dataImport/designs/dataImport.css +98 -0
  7. package/src/dataImport/designs/index.js +2 -0
  8. package/src/dataImport/pages.jsx +17 -0
  9. package/src/dataImport/useDataImportController.js +94 -0
  10. package/src/dataImport/useImportHistoryController.js +67 -0
  11. package/src/dataImport/validateDesign.js +68 -0
  12. package/src/dateField/dateField.js +71 -0
  13. package/src/dateField/designs/DateFieldSample.jsx +30 -0
  14. package/src/dateField/designs/dateField.css +60 -0
  15. package/src/dateField/designs/index.js +1 -0
  16. package/src/dateField/pages.jsx +10 -0
  17. package/src/dateField/useDateFieldController.js +77 -0
  18. package/src/dateField/validateDesign.js +3 -0
  19. package/src/dateRange/dateRange.js +273 -0
  20. package/src/dateRange/designs/DateRangeSample.jsx +138 -0
  21. package/src/dateRange/designs/dateRange.css +69 -0
  22. package/src/dateRange/designs/index.js +1 -0
  23. package/src/dateRange/pages.jsx +10 -0
  24. package/src/dateRange/useDateRangeController.js +118 -0
  25. package/src/dateRange/validateDesign.js +20 -0
  26. package/src/dropdown/designs/DropdownSample.jsx +151 -0
  27. package/src/dropdown/designs/dropdown.css +226 -0
  28. package/src/dropdown/designs/index.js +1 -0
  29. package/src/dropdown/dropdown.js +75 -0
  30. package/src/dropdown/pages.jsx +10 -0
  31. package/src/dropdown/useDropdownController.js +254 -0
  32. package/src/dropdown/validateDesign.js +4 -0
  33. package/src/fileUpload/validateDesign.js +27 -8
  34. package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
  35. package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
  36. package/src/gridDisplayer/designs/index.js +1 -0
  37. package/src/gridDisplayer/gridDisplayer.js +44 -0
  38. package/src/gridDisplayer/pages.jsx +10 -0
  39. package/src/gridDisplayer/useGridDisplayerController.js +59 -0
  40. package/src/gridDisplayer/validateDesign.js +4 -0
  41. package/src/index.js +177 -1
  42. package/src/numberField/designs/NumberFieldSample.jsx +52 -0
  43. package/src/numberField/designs/index.js +1 -0
  44. package/src/numberField/designs/numberField.css +66 -0
  45. package/src/numberField/numberField.js +105 -0
  46. package/src/numberField/pages.jsx +10 -0
  47. package/src/numberField/useNumberFieldController.js +116 -0
  48. package/src/numberField/validateDesign.js +3 -0
  49. package/src/progress/ProgressNotifier.jsx +118 -0
  50. package/src/progress/designs/progressNotifier.css +73 -0
  51. package/src/progress/progressSource.js +168 -0
  52. package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
  53. package/src/rangeField/designs/index.js +1 -0
  54. package/src/rangeField/designs/rangeField.css +26 -0
  55. package/src/rangeField/pages.jsx +10 -0
  56. package/src/rangeField/rangeField.js +29 -0
  57. package/src/rangeField/useRangeFieldController.js +52 -0
  58. package/src/rangeField/validateDesign.js +3 -0
  59. package/src/snackbar/snackbar.js +6 -3
  60. package/src/textField/designs/TextFieldSample.jsx +75 -0
  61. package/src/textField/designs/index.js +1 -0
  62. package/src/textField/designs/textField.css +92 -0
  63. package/src/textField/pages.jsx +10 -0
  64. package/src/textField/textField.js +65 -0
  65. package/src/textField/useTextFieldController.js +101 -0
  66. package/src/textField/validateDesign.js +3 -0
  67. package/src/theme/default.theme.json +934 -0
  68. package/src/theme/index.js +155 -0
@@ -0,0 +1,67 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * @param {object} options
5
+ * @param {() => Promise<{jobs: object[]}|object[]>} options.onList
6
+ * @param {(job: object) => Promise<any>} options.onRollback
7
+ * @param {(job: object) => Promise<any>} [options.onDelete]
8
+ *
9
+ * Job shape (generic — same part/target vocabulary as useDataImportController):
10
+ * { id, originalFileName, part, target, rows, status, startedAt, error,
11
+ * destination?, rowsLoaded?, rowsDropped?, createdByName? }
12
+ * status: 'completed' | 'failed' | 'rolled-back' — only 'completed' rows are
13
+ * rollback-able; the design decides how to render the others.
14
+ * The `?` fields are optional context (where it went beyond the target table,
15
+ * a loaded/dropped breakdown, who ran it) — apps that don't track them can
16
+ * omit them and the design falls back to '—'.
17
+ */
18
+ export function useImportHistoryController(options = {}) {
19
+ var [jobs, setJobs] = useState([]);
20
+ var [loading, setLoading] = useState(false);
21
+ var [error, setError] = useState('');
22
+ var [rollingBackId, setRollingBackId] = useState(null);
23
+ var [deletingId, setDeletingId] = useState(null);
24
+
25
+ var load = useCallback(async function() {
26
+ setLoading(true);
27
+ setError('');
28
+ try {
29
+ var res = await options.onList();
30
+ setJobs((res && res.jobs) || res || []);
31
+ } catch (err) {
32
+ setError(err.message || 'Failed to load import history');
33
+ } finally {
34
+ setLoading(false);
35
+ }
36
+ }, [options.onList]);
37
+
38
+ useEffect(function() { load(); }, [load]);
39
+
40
+ async function handleRollback(job) {
41
+ setRollingBackId(job.id);
42
+ setError('');
43
+ try {
44
+ await options.onRollback(job);
45
+ await load();
46
+ } catch (err) {
47
+ setError(err.message || 'Rollback failed');
48
+ } finally {
49
+ setRollingBackId(null);
50
+ }
51
+ }
52
+
53
+ async function handleDelete(job) {
54
+ setDeletingId(job.id);
55
+ setError('');
56
+ try {
57
+ await options.onDelete(job);
58
+ await load();
59
+ } catch (err) {
60
+ setError(err.message || 'Delete failed');
61
+ } finally {
62
+ setDeletingId(null);
63
+ }
64
+ }
65
+
66
+ return { jobs, loading, error, rollingBackId, deletingId, reload: load, handleRollback, handleDelete };
67
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
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.
12
+ *
13
+ * @param {string} componentName - Name of the page (for error messages)
14
+ * @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
15
+ */
16
+ export function useDesignValidator(componentName, requiredElements) {
17
+ var containerRef = useRef(null);
18
+
19
+ useEffect(function() {
20
+ if (!containerRef.current) return;
21
+
22
+ var missing = [];
23
+ for (var i = 0; i < requiredElements.length; i++) {
24
+ var rule = requiredElements[i];
25
+ var found = false;
26
+
27
+ if (rule.id) {
28
+ found = !!containerRef.current.querySelector('#' + rule.id);
29
+ } else if (rule.role) {
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
+ }
35
+ } else if (rule.selector) {
36
+ found = !!containerRef.current.querySelector(rule.selector);
37
+ }
38
+
39
+ if (!found) {
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);
44
+ }
45
+ }
46
+
47
+ if (missing.length > 0) {
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);
58
+ }
59
+ }, []);
60
+
61
+ return containerRef;
62
+ }
63
+
64
+ export var DATA_IMPORT_RULES = [
65
+ { selector: 'input[type="file"]', label: 'File input' }
66
+ ];
67
+
68
+ export var IMPORT_HISTORY_RULES = [];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * DateField — pure model. No React.
3
+ *
4
+ * Three flavours:
5
+ * - 'date' → ISO date string (YYYY-MM-DD)
6
+ * - 'datetime' → ISO datetime string (YYYY-MM-DDTHH:mm)
7
+ * - 'age' → input is a DOB (date), output value is the DOB but the
8
+ * age in years is computed and returned alongside.
9
+ */
10
+
11
+ export var DATE_TYPES = ['date', 'datetime', 'age'];
12
+
13
+ export function isValidISODate(s) {
14
+ if (typeof s !== 'string' || s.length === 0) return false;
15
+ var d = new Date(s);
16
+ return !isNaN(d.getTime());
17
+ }
18
+
19
+ /**
20
+ * Calculate completed years between dob and `now`. Returns null if dob is invalid.
21
+ */
22
+ export function calculateAge(dob, now) {
23
+ if (!dob) return null;
24
+ var birth = dob instanceof Date ? dob : new Date(dob);
25
+ if (isNaN(birth.getTime())) return null;
26
+ var ref = now || new Date();
27
+ var years = ref.getFullYear() - birth.getFullYear();
28
+ var monthDiff = ref.getMonth() - birth.getMonth();
29
+ if (monthDiff < 0 || (monthDiff === 0 && ref.getDate() < birth.getDate())) {
30
+ years--;
31
+ }
32
+ return years < 0 ? null : years;
33
+ }
34
+
35
+ /**
36
+ * Map our public type to the underlying <input> type attribute.
37
+ */
38
+ export function inputTypeFor(type) {
39
+ if (type === 'datetime') return 'datetime-local';
40
+ return 'date';
41
+ }
42
+
43
+ /**
44
+ * Normalise a Date or string into the format the <input> element expects.
45
+ */
46
+ export function toInputValue(value, type) {
47
+ if (!value) return '';
48
+ var d = value instanceof Date ? value : new Date(value);
49
+ if (isNaN(d.getTime())) return '';
50
+ var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
51
+ var ymd = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
52
+ if (type === 'datetime') {
53
+ return ymd + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
54
+ }
55
+ return ymd;
56
+ }
57
+
58
+ export function validateBounds(value, min, max) {
59
+ if (!value) return { valid: true };
60
+ var d = value instanceof Date ? value : new Date(value);
61
+ if (isNaN(d.getTime())) return { valid: false, error: 'Invalid date' };
62
+ if (min) {
63
+ var dMin = min instanceof Date ? min : new Date(min);
64
+ if (d < dMin) return { valid: false, error: 'Date is before minimum' };
65
+ }
66
+ if (max) {
67
+ var dMax = max instanceof Date ? max : new Date(max);
68
+ if (d > dMax) return { valid: false, error: 'Date is after maximum' };
69
+ }
70
+ return { valid: true };
71
+ }
@@ -0,0 +1,30 @@
1
+ import './dateField.css';
2
+
3
+ export default function DateFieldSample({
4
+ type, inputType, raw, age, error, placeholder, disabled, min, max,
5
+ handleChange
6
+ }) {
7
+ return (
8
+ <div className={'xeplr-datefield xeplr-datefield-' + type + (error ? ' xeplr-datefield-error' : '')}>
9
+ <div className="xeplr-datefield-row">
10
+ <input
11
+ type={inputType}
12
+ className="xeplr-datefield-input"
13
+ value={raw}
14
+ placeholder={placeholder}
15
+ disabled={disabled}
16
+ min={min}
17
+ max={max}
18
+ onChange={handleChange}
19
+ />
20
+ {type === 'age' && (
21
+ <div className="xeplr-datefield-age">
22
+ <span className="xeplr-datefield-age-label">Age</span>
23
+ <span className="xeplr-datefield-age-value">{age === null ? '—' : age + ' yrs'}</span>
24
+ </div>
25
+ )}
26
+ </div>
27
+ {error && <div className="xeplr-datefield-error-text">{error}</div>}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,60 @@
1
+ .xeplr-datefield {
2
+ font-family: system-ui, -apple-system, sans-serif;
3
+ color: #e0e0e0;
4
+ }
5
+
6
+ .xeplr-datefield-row {
7
+ display: flex;
8
+ align-items: stretch;
9
+ gap: 10px;
10
+ }
11
+
12
+ .xeplr-datefield-input {
13
+ flex: 1;
14
+ background: #1a1a1a;
15
+ border: 1px solid #444;
16
+ border-radius: 6px;
17
+ padding: 9px 12px;
18
+ color: inherit;
19
+ font-size: 14px;
20
+ outline: none;
21
+ color-scheme: dark;
22
+ }
23
+
24
+ .xeplr-datefield-input:focus {
25
+ border-color: #646cff;
26
+ box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.18);
27
+ }
28
+
29
+ .xeplr-datefield-error .xeplr-datefield-input {
30
+ border-color: #e25555;
31
+ }
32
+
33
+ .xeplr-datefield-age {
34
+ display: flex;
35
+ flex-direction: column;
36
+ justify-content: center;
37
+ background: #232540;
38
+ border-radius: 6px;
39
+ padding: 4px 14px;
40
+ min-width: 90px;
41
+ }
42
+
43
+ .xeplr-datefield-age-label {
44
+ font-size: 10px;
45
+ text-transform: uppercase;
46
+ letter-spacing: 0.6px;
47
+ color: #888bbf;
48
+ }
49
+
50
+ .xeplr-datefield-age-value {
51
+ font-size: 14px;
52
+ font-weight: 600;
53
+ color: #e0e0e0;
54
+ }
55
+
56
+ .xeplr-datefield-error-text {
57
+ margin-top: 4px;
58
+ font-size: 12px;
59
+ color: #e25555;
60
+ }
@@ -0,0 +1 @@
1
+ export { default as DateFieldSample } from './DateFieldSample.jsx';
@@ -0,0 +1,10 @@
1
+ import { useDateFieldController } from './useDateFieldController.js';
2
+ import DateFieldSample from './designs/DateFieldSample.jsx';
3
+ import { useDesignValidator } from '../fileUpload/validateDesign.js';
4
+ import { DATE_FIELD_RULES } from './validateDesign.js';
5
+
6
+ export function DateFieldPage(props) {
7
+ var controller = useDateFieldController(props);
8
+ var ref = useDesignValidator('DateFieldPage', DATE_FIELD_RULES);
9
+ return <div ref={ref}><DateFieldSample {...controller} /></div>;
10
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import {
3
+ DATE_TYPES,
4
+ calculateAge,
5
+ inputTypeFor,
6
+ toInputValue,
7
+ validateBounds
8
+ } from './dateField.js';
9
+
10
+ /**
11
+ * @param {object} props
12
+ * @param {'date'|'datetime'|'age'} [props.type='date']
13
+ * @param {Date|string|null} [props.value] — controlled value
14
+ * @param {Date|string|null} [props.defaultValue] — uncontrolled initial value
15
+ * @param {(value, meta) => void} [props.onChange] — meta.age included for age type
16
+ * @param {Date|string} [props.min]
17
+ * @param {Date|string} [props.max]
18
+ * @param {string} [props.placeholder]
19
+ * @param {boolean} [props.disabled]
20
+ */
21
+ export function useDateFieldController(props) {
22
+ props = props || {};
23
+ var type = props.type || 'date';
24
+ if (DATE_TYPES.indexOf(type) === -1) {
25
+ throw new Error('[xeplr-ui-utils:DateField] Unknown type: ' + type + '. Expected one of ' + DATE_TYPES.join(', '));
26
+ }
27
+
28
+ var isControlled = props.value !== undefined;
29
+ var initial = isControlled ? props.value : (props.defaultValue !== undefined ? props.defaultValue : null);
30
+
31
+ var [raw, setRaw] = useState(toInputValue(initial, type === 'age' ? 'date' : type));
32
+ var [error, setError] = useState('');
33
+
34
+ useEffect(function() {
35
+ if (isControlled) {
36
+ setRaw(toInputValue(props.value, type === 'age' ? 'date' : type));
37
+ }
38
+ }, [props.value, isControlled, type]);
39
+
40
+ var inputType = useMemo(function() {
41
+ return inputTypeFor(type === 'age' ? 'date' : type);
42
+ }, [type]);
43
+
44
+ var age = useMemo(function() {
45
+ if (type !== 'age') return null;
46
+ return calculateAge(raw);
47
+ }, [type, raw]);
48
+
49
+ function emit(nextRaw) {
50
+ var bounds = validateBounds(nextRaw, props.min, props.max);
51
+ setError(bounds.valid ? '' : bounds.error);
52
+ if (props.onChange) {
53
+ var meta = {};
54
+ if (type === 'age') meta.age = calculateAge(nextRaw);
55
+ props.onChange(nextRaw || null, meta);
56
+ }
57
+ }
58
+
59
+ function handleChange(e) {
60
+ var v = e.target.value;
61
+ if (!isControlled) setRaw(v);
62
+ emit(v);
63
+ }
64
+
65
+ return {
66
+ type,
67
+ inputType,
68
+ raw,
69
+ age,
70
+ error,
71
+ placeholder: props.placeholder,
72
+ disabled: !!props.disabled,
73
+ min: props.min ? toInputValue(props.min, type === 'age' ? 'date' : type) : undefined,
74
+ max: props.max ? toInputValue(props.max, type === 'age' ? 'date' : type) : undefined,
75
+ handleChange
76
+ };
77
+ }
@@ -0,0 +1,3 @@
1
+ export var DATE_FIELD_RULES = [
2
+ { selector: 'input[type="date"], input[type="datetime-local"], input.xeplr-datefield-input', label: 'Date input element' }
3
+ ];
@@ -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
+ }