@servicetitan/dte-pdf-editor 1.35.0 → 1.37.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.
Files changed (85) hide show
  1. package/README.md +28 -12
  2. package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
  3. package/dist/components/field-config-panel/advanced-settings.js +20 -10
  4. package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
  5. package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -1
  6. package/dist/components/field-config-panel/formula-generator.js +2 -1
  7. package/dist/components/field-config-panel/formula-generator.js.map +1 -1
  8. package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
  9. package/dist/components/field-config-panel/formula-modal.js +48 -7
  10. package/dist/components/field-config-panel/formula-modal.js.map +1 -1
  11. package/dist/components/field-config-panel/formula-workspace.d.ts +1 -0
  12. package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -1
  13. package/dist/components/field-config-panel/formula-workspace.js +10 -4
  14. package/dist/components/field-config-panel/formula-workspace.js.map +1 -1
  15. package/dist/components/field-config-panel/result-type-selector.d.ts.map +1 -1
  16. package/dist/components/field-config-panel/result-type-selector.js +1 -0
  17. package/dist/components/field-config-panel/result-type-selector.js.map +1 -1
  18. package/dist/components/pdf-view/pdf-view-calculated.d.ts +1 -0
  19. package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
  20. package/dist/components/pdf-view/pdf-view-calculated.js +20 -3
  21. package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
  22. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  23. package/dist/components/pdf-view/pdf-view-fillable.js +5 -2
  24. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  25. package/dist/components/pdf-view/pdf-view.d.ts +6 -0
  26. package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
  27. package/dist/components/pdf-view/pdf-view.js +2 -2
  28. package/dist/components/pdf-view/pdf-view.js.map +1 -1
  29. package/dist/constants/calculated.constants.d.ts +2 -0
  30. package/dist/constants/calculated.constants.d.ts.map +1 -1
  31. package/dist/constants/calculated.constants.js +2 -0
  32. package/dist/constants/calculated.constants.js.map +1 -1
  33. package/dist/hooks/useFormulaEditor.d.ts +3 -0
  34. package/dist/hooks/useFormulaEditor.d.ts.map +1 -1
  35. package/dist/hooks/useFormulaEditor.js +24 -1
  36. package/dist/hooks/useFormulaEditor.js.map +1 -1
  37. package/dist/interface/types.d.ts +6 -2
  38. package/dist/interface/types.d.ts.map +1 -1
  39. package/dist/interface/types.js.map +1 -1
  40. package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
  41. package/dist/utils/data-model/extract-fields.utils.js +11 -1
  42. package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
  43. package/dist/utils/formula/evaluate-formula.utils.d.ts +10 -2
  44. package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -1
  45. package/dist/utils/formula/evaluate-formula.utils.js +205 -4
  46. package/dist/utils/formula/evaluate-formula.utils.js.map +1 -1
  47. package/dist/utils/formula/expression.utils.d.ts +3 -3
  48. package/dist/utils/formula/expression.utils.d.ts.map +1 -1
  49. package/dist/utils/formula/expression.utils.js +3 -5
  50. package/dist/utils/formula/expression.utils.js.map +1 -1
  51. package/dist/utils/formula/format-calculated-result.utils.d.ts +1 -1
  52. package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -1
  53. package/dist/utils/formula/format-calculated-result.utils.js +29 -8
  54. package/dist/utils/formula/format-calculated-result.utils.js.map +1 -1
  55. package/dist/utils/formula/validate-formula.utils.d.ts +1 -1
  56. package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -1
  57. package/dist/utils/formula/validate-formula.utils.js +18 -3
  58. package/dist/utils/formula/validate-formula.utils.js.map +1 -1
  59. package/dist/utils/shared/date.utils.d.ts +2 -0
  60. package/dist/utils/shared/date.utils.d.ts.map +1 -0
  61. package/dist/utils/shared/date.utils.js +6 -0
  62. package/dist/utils/shared/date.utils.js.map +1 -0
  63. package/dist/utils/shared/index.d.ts +1 -0
  64. package/dist/utils/shared/index.d.ts.map +1 -1
  65. package/dist/utils/shared/index.js +1 -0
  66. package/dist/utils/shared/index.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/components/field-config-panel/advanced-settings.tsx +103 -77
  69. package/src/components/field-config-panel/formula-generator.tsx +2 -1
  70. package/src/components/field-config-panel/formula-modal.tsx +48 -8
  71. package/src/components/field-config-panel/formula-workspace.tsx +21 -7
  72. package/src/components/field-config-panel/result-type-selector.tsx +1 -0
  73. package/src/components/pdf-view/pdf-view-calculated.tsx +20 -3
  74. package/src/components/pdf-view/pdf-view-fillable.tsx +6 -3
  75. package/src/components/pdf-view/pdf-view.tsx +8 -0
  76. package/src/constants/calculated.constants.ts +4 -0
  77. package/src/hooks/useFormulaEditor.ts +32 -1
  78. package/src/interface/types.ts +8 -2
  79. package/src/utils/data-model/extract-fields.utils.ts +13 -1
  80. package/src/utils/formula/evaluate-formula.utils.ts +271 -5
  81. package/src/utils/formula/expression.utils.ts +3 -8
  82. package/src/utils/formula/format-calculated-result.utils.ts +32 -10
  83. package/src/utils/formula/validate-formula.utils.ts +27 -1
  84. package/src/utils/shared/date.utils.ts +6 -0
  85. package/src/utils/shared/index.ts +1 -0
@@ -17,6 +17,7 @@ import { ResultTypeSelector } from './result-type-selector';
17
17
  interface FormulaWorkspaceProps {
18
18
  actions: ReactNode;
19
19
  advancedOpen: boolean;
20
+ disabledOperators?: Set<string>;
20
21
  editorRef: RefObject<HTMLDivElement | null>;
21
22
  format: CalculatedFieldFormat;
22
23
  isInvalid: boolean;
@@ -36,6 +37,7 @@ interface FormulaWorkspaceProps {
36
37
  export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
37
38
  actions,
38
39
  advancedOpen,
40
+ disabledOperators,
39
41
  editorRef,
40
42
  format,
41
43
  isInvalid,
@@ -73,8 +75,11 @@ export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
73
75
  return (
74
76
  <Flex direction="column" flex={1} gap={2} style={{ padding: '12px' }}>
75
77
  <Text variant="body" size="small">
76
- Click a field on the left to add it. Use +, -, *, /, and parentheses to build
77
- formulas.
78
+ Click a field on the left to add it. Use +, -,{' '}
79
+ {disabledOperators?.has('*') ? '' : '*, /, and '}parentheses to build formulas.
80
+ {disabledOperators?.has('*')
81
+ ? ' Multiply and divide are not available with date fields.'
82
+ : ''}
78
83
  </Text>
79
84
  <Flex alignItems="center" justifyContent="space-between">
80
85
  <Text variant="body" size="small">
@@ -101,11 +106,20 @@ export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
101
106
  />
102
107
  {isInvalid && validationError && <Alert status="danger" title={validationError} />}
103
108
  <Flex gap={1}>
104
- {CALCULATED_OPERATIONS.map(op => (
105
- <Button key={op} onClick={() => onOperatorSelect(op)} size="small">
106
- {op}
107
- </Button>
108
- ))}
109
+ {CALCULATED_OPERATIONS.map(op => {
110
+ const isDisabled = disabledOperators?.has(op) ?? false;
111
+ return (
112
+ <Button
113
+ key={op}
114
+ onClick={() => onOperatorSelect(op)}
115
+ size="small"
116
+ disabled={isDisabled}
117
+ title={isDisabled ? 'Not available with date fields' : undefined}
118
+ >
119
+ {op}
120
+ </Button>
121
+ );
122
+ })}
109
123
  </Flex>
110
124
  <ResultTypeSelector format={format} onChange={onResultTypeChange} />
111
125
  <LinkButton appearance="primary" onClick={onToggleAdvanced}>
@@ -11,6 +11,7 @@ const RESULT_TYPE_OPTIONS: { label: string; value: CalculatedFieldFormat['result
11
11
  { label: 'Number', value: 'number' },
12
12
  { label: 'Currency', value: 'currency' },
13
13
  { label: 'Percent', value: 'percent' },
14
+ { label: 'Date', value: 'date' },
14
15
  ];
15
16
 
16
17
  export const ResultTypeSelector: FC<ResultTypeSelectorProps> = ({ format, onChange }) => (
@@ -5,19 +5,36 @@ import { evaluateFormula, formatCalculatedResult, resolvePdfDataValues } from '.
5
5
  interface PdfViewCalculatedProps {
6
6
  field: PdfField;
7
7
  data?: DataModelValues;
8
+ holidays?: string[];
8
9
  }
9
10
 
10
- export const PdfViewCalculated: FC<PdfViewCalculatedProps> = ({ data, field }) => {
11
+ export const PdfViewCalculated: FC<PdfViewCalculatedProps> = ({ data, field, holidays }) => {
12
+ const dateFieldPaths = useMemo(() => {
13
+ const paths = field.formulaFormat?.dateFieldPaths;
14
+ return paths?.length ? new Set(paths) : undefined;
15
+ }, [field.formulaFormat?.dateFieldPaths]);
16
+
11
17
  const displayValue = useMemo(() => {
12
18
  if (field.formula?.tokens?.length) {
13
- const value = evaluateFormula(field.formula, data);
19
+ const value = evaluateFormula(field.formula, data, dateFieldPaths, holidays);
20
+ if (value === null) {
21
+ return '';
22
+ }
14
23
  return formatCalculatedResult(value, field.formulaFormat);
15
24
  }
16
25
  if (field.path) {
17
26
  return resolvePdfDataValues(data, field.path) ?? '';
18
27
  }
19
28
  return field.label ?? '';
20
- }, [data, field.formula, field.formulaFormat, field.label, field.path]);
29
+ }, [
30
+ data,
31
+ dateFieldPaths,
32
+ field.formula,
33
+ field.formulaFormat,
34
+ field.label,
35
+ field.path,
36
+ holidays,
37
+ ]);
21
38
 
22
39
  return <div className="dte-pdf-field-value">{displayValue}</div>;
23
40
  };
@@ -1,6 +1,6 @@
1
1
  import { FC } from 'react';
2
2
  import { DataChangePayload, DataModelValues, PdfField, PreviewMode } from '../../interface/types';
3
- import { getFieldPlaceholderText } from '../../utils';
3
+ import { dateToUtcZero, getFieldPlaceholderText } from '../../utils';
4
4
 
5
5
  interface PdfViewFillableProps {
6
6
  field: PdfField;
@@ -103,8 +103,11 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
103
103
  disabled={!isFillable}
104
104
  id={field.path}
105
105
  name={field.path}
106
- value={resolvedValue ?? ''}
107
- onChange={e => handleDataChange(e.target.value)}
106
+ value={resolvedValue ? resolvedValue.slice(0, 10) : ''}
107
+ onChange={e => {
108
+ const dateStr = e.target.value;
109
+ handleDataChange(dateStr ? dateToUtcZero(dateStr) : '');
110
+ }}
108
111
  placeholder={getFieldPlaceholderText(field, 'mm/dd/yyyy')}
109
112
  />
110
113
  );
@@ -25,6 +25,12 @@ export interface PdfViewProps {
25
25
  data?: DataModelValues;
26
26
  pdfUrl: string;
27
27
  loading?: boolean;
28
+ /**
29
+ * UTC date strings (e.g. `'2026-03-17T00:00:00Z'`) treated as non-business days.
30
+ * When provided, date-based calculated fields skip weekends and these holidays
31
+ * when adding/subtracting days or computing date differences.
32
+ */
33
+ holidays?: string[];
28
34
  /*
29
35
  * fillingBy defines the list of recipient names
30
36
  * who are allowed to fill this field.
@@ -45,6 +51,7 @@ export const PdfView: FC<PdfViewProps> = ({
45
51
  errors = {},
46
52
  fields,
47
53
  fillingBy,
54
+ holidays,
48
55
  loading = false,
49
56
  loadingPlaceholder,
50
57
  onDataChange,
@@ -106,6 +113,7 @@ export const PdfView: FC<PdfViewProps> = ({
106
113
  <PdfViewCalculated
107
114
  field={field}
108
115
  data={previewMode === 'fillable' ? previewData : data}
116
+ holidays={holidays}
109
117
  />
110
118
  )}
111
119
  {field.type === FieldTypeEnum.fillable && (
@@ -1 +1,5 @@
1
1
  export const CALCULATED_OPERATIONS = ['+', '-', '*', '/', '(', ')'];
2
+
3
+ export const DEFAULT_DATE_FORMAT = 'MM/DD/YYYY';
4
+
5
+ export const MAX_DATE_CALC_DAYS = 365;
@@ -18,10 +18,11 @@ import {
18
18
 
19
19
  export const useFormulaEditor = (params: {
20
20
  currentExpression: string;
21
+ knownDateFields?: Set<string>;
21
22
  opened: boolean;
22
23
  pathToLabel: Map<string, string>;
23
24
  }) => {
24
- const { currentExpression, opened, pathToLabel } = params;
25
+ const { currentExpression, knownDateFields, opened, pathToLabel } = params;
25
26
  const [draftExpression, setDraftExpression] = useState(currentExpression);
26
27
  const [history, setHistory] = useState<string[]>([currentExpression]);
27
28
  const [historyIndex, setHistoryIndex] = useState(0);
@@ -32,6 +33,7 @@ export const useFormulaEditor = (params: {
32
33
  const lastCaretRef = useRef(0);
33
34
  const historyIndexRef = useRef(0);
34
35
  const historyRef = useRef<string[]>([currentExpression]);
36
+ const knownDateFieldsRef = useRef(knownDateFields);
35
37
 
36
38
  useEffect(() => {
37
39
  if (opened) {
@@ -210,6 +212,10 @@ export const useFormulaEditor = (params: {
210
212
  [draftExpression],
211
213
  );
212
214
 
215
+ useEffect(() => {
216
+ knownDateFieldsRef.current = knownDateFields;
217
+ }, [knownDateFields]);
218
+
213
219
  const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
214
220
  if (e.ctrlKey || e.metaKey) {
215
221
  const key = e.key.toLowerCase();
@@ -240,6 +246,16 @@ export const useFormulaEditor = (params: {
240
246
  return;
241
247
  }
242
248
  }
249
+ if (knownDateFieldsRef.current?.size && (e.key === '*' || e.key === '/')) {
250
+ const parts = tokenizeExpression(readExpressionFromEditor(editorRef.current!));
251
+ const hasDate = parts.some(
252
+ p => p.type === 'field' && knownDateFieldsRef.current!.has(p.value),
253
+ );
254
+ if (hasDate) {
255
+ e.preventDefault();
256
+ return;
257
+ }
258
+ }
243
259
  const allowed = new Set([
244
260
  '+',
245
261
  '-',
@@ -331,9 +347,24 @@ export const useFormulaEditor = (params: {
331
347
  return new Set(parts.filter(p => p.type === 'field').map(p => p.value));
332
348
  }, [draftExpression]);
333
349
 
350
+ const expressionHasDateFields = useMemo(() => {
351
+ if (!knownDateFields?.size) {
352
+ return false;
353
+ }
354
+ const parts = tokenizeExpression(draftExpression);
355
+ return parts.some(p => p.type === 'field' && knownDateFields.has(p.value));
356
+ }, [draftExpression, knownDateFields]);
357
+
358
+ const disabledOperators = useMemo(
359
+ () => (expressionHasDateFields ? new Set(['*', '/']) : undefined),
360
+ [expressionHasDateFields],
361
+ );
362
+
334
363
  return {
335
364
  draftExpression,
365
+ disabledOperators,
336
366
  editorRef,
367
+ expressionHasDateFields,
337
368
  isDirty,
338
369
  canUndo: historyIndex > 0,
339
370
  canRedo: historyIndex < history.length - 1,
@@ -80,11 +80,14 @@ export interface PdfField {
80
80
  displayCondition?: DisplayConditionState | null;
81
81
  }
82
82
 
83
+ export type SchemaFieldType = 'number' | 'date';
84
+
83
85
  export interface FieldTypeOption {
84
86
  label: string;
85
87
  type: FieldTypeEnum;
86
88
  subType?: PdfFieldSubType;
87
89
  path?: string;
90
+ fieldType?: SchemaFieldType;
88
91
  }
89
92
 
90
93
  export interface DataModelFieldGroup {
@@ -102,7 +105,7 @@ export type FormulaToken =
102
105
  | { type: 'operator'; value: FormulaOperator }
103
106
  | { type: 'lparen'; value: '(' }
104
107
  | { type: 'rparen'; value: ')' }
105
- | { type: 'field'; path: string; label: string };
108
+ | { type: 'field'; path: string };
106
109
 
107
110
  /** Structured formula representation (AST-like token list) for validation and safe editing */
108
111
  export interface StructuredFormula {
@@ -111,7 +114,7 @@ export interface StructuredFormula {
111
114
 
112
115
  /** Format options for a calculated field result (display and rounding) */
113
116
  export interface CalculatedFieldFormat {
114
- resultType: 'number' | 'currency' | 'percent';
117
+ resultType: 'number' | 'currency' | 'percent' | 'date';
115
118
  thousandsSeparator: boolean;
116
119
  decimals: number;
117
120
  roundingMode: 'round' | 'floor' | 'ceil';
@@ -119,6 +122,9 @@ export interface CalculatedFieldFormat {
119
122
  decimalSeparator: '.' | ',';
120
123
  prefixText: string;
121
124
  postfixText: string;
125
+ dateFormat?: string;
126
+ /** Paths of date-typed fields used in the formula, persisted for date-aware evaluation */
127
+ dateFieldPaths?: string[];
122
128
  }
123
129
 
124
130
  export interface SchemaFieldBaseOptions {
@@ -2,6 +2,7 @@ import {
2
2
  DataModelFieldGroup,
3
3
  FieldTypeEnum,
4
4
  FieldTypeOption,
5
+ SchemaFieldType,
5
6
  SchemaNode,
6
7
  SchemaObject,
7
8
  SchemaSimpleDate,
@@ -16,6 +17,16 @@ function isSchemaSimpleDate(node: SchemaNode): node is SchemaSimpleDate {
16
17
  return node.type === 'date';
17
18
  }
18
19
 
20
+ function getSchemaFieldType(node: SchemaNode): SchemaFieldType | undefined {
21
+ if (isSchemaSimpleNumber(node)) {
22
+ return 'number';
23
+ }
24
+ if (isSchemaSimpleDate(node)) {
25
+ return 'date';
26
+ }
27
+ return undefined;
28
+ }
29
+
19
30
  function useInCalculatedFields(node: SchemaNode): boolean {
20
31
  return node.options?.useInCalculatedFields === true;
21
32
  }
@@ -79,6 +90,7 @@ export const extractGroupedFieldsFromDataModel = (
79
90
  label: property.title ?? key,
80
91
  type: FieldTypeEnum.dataModel,
81
92
  path: key,
93
+ fieldType: getSchemaFieldType(property),
82
94
  });
83
95
  }
84
96
  }
@@ -114,12 +126,12 @@ const extractFieldsRecursive = (
114
126
  rawType === 'boolean';
115
127
  const includeField = !onlyUseInCalculatedFields || useInCalculatedFields(fieldProperty);
116
128
  if (isLeaf && includeField) {
117
- // Leaf property - add as a field
118
129
  const label = fieldProperty.title ?? fieldKey;
119
130
  fields.push({
120
131
  label,
121
132
  type: FieldTypeEnum.dataModel,
122
133
  path: currentPath,
134
+ fieldType: getSchemaFieldType(fieldProperty),
123
135
  });
124
136
  } else if (fieldProperty.type === 'object' && fieldProperty.properties) {
125
137
  extractFieldsRecursive(
@@ -28,6 +28,26 @@ export function valueToNumber(raw: unknown): number {
28
28
  return Number.isNaN(n) ? 0 : n;
29
29
  }
30
30
 
31
+ function hasAllFieldValues(
32
+ formula: StructuredFormula,
33
+ data: DataModelValues | undefined,
34
+ dateFieldPaths?: Set<string>,
35
+ ): boolean {
36
+ return formula.tokens.every((t: FormulaToken) => {
37
+ if (t.type !== 'field') {
38
+ return true;
39
+ }
40
+ const raw = resolvePdfDataValues(data, t.path);
41
+ if (raw === '') {
42
+ return false;
43
+ }
44
+ if (dateFieldPaths?.has(t.path)) {
45
+ return !isNaN(new Date(raw).getTime());
46
+ }
47
+ return !isNaN(Number(raw.trim()));
48
+ });
49
+ }
50
+
31
51
  type ResolvedToken =
32
52
  | { type: 'number'; value: number }
33
53
  | { type: 'operator'; value: FormulaOperator }
@@ -66,7 +86,7 @@ function applyOp(a: number, op: FormulaOperator, b: number): number {
66
86
  }
67
87
 
68
88
  /** Find the index of the matching closing paren for an open paren at openIndex. */
69
- function findMatchingParen(tokens: ResolvedToken[], openIndex: number): number {
89
+ function findMatchingParen<T extends { type: string }>(tokens: T[], openIndex: number): number {
70
90
  let balance = 1;
71
91
  for (let i = openIndex + 1; i < tokens.length; i++) {
72
92
  const t = tokens[i];
@@ -123,7 +143,6 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
123
143
 
124
144
  const list = [...tokens];
125
145
 
126
- // Resolve innermost parentheses first
127
146
  let idx = 0;
128
147
  while (idx < list.length) {
129
148
  if (list[idx].type === 'lparen') {
@@ -142,18 +161,265 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
142
161
  return evalFlat(list);
143
162
  }
144
163
 
164
+ /*
165
+ * ---------------------------------------------------------------------------
166
+ * Date-aware evaluation (business-day logic: skips weekends & holidays)
167
+ * ---------------------------------------------------------------------------
168
+ */
169
+
170
+ type ValueKind = 'number' | 'date';
171
+
172
+ interface TypedValue {
173
+ kind: ValueKind;
174
+ value: number;
175
+ }
176
+
177
+ type DateAwareToken =
178
+ | TypedValue
179
+ | { type: 'operator'; value: FormulaOperator }
180
+ | { type: 'lparen'; value: '(' }
181
+ | { type: 'rparen'; value: ')' };
182
+
183
+ function isTypedValue(t: DateAwareToken): t is TypedValue {
184
+ return 'kind' in t;
185
+ }
186
+
187
+ function parseDateValue(raw: string): number {
188
+ const d = new Date(raw);
189
+ return isNaN(d.getTime()) ? NaN : d.getTime();
190
+ }
191
+
192
+ function toDateOnly(ms: number): Date {
193
+ const d = new Date(ms);
194
+ d.setUTCHours(0, 0, 0, 0);
195
+ return d;
196
+ }
197
+
198
+ function toDateKey(d: Date): string {
199
+ const y = d.getUTCFullYear();
200
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
201
+ const day = String(d.getUTCDate()).padStart(2, '0');
202
+ return `${y}-${m}-${day}`;
203
+ }
204
+
205
+ function isWeekend(d: Date): boolean {
206
+ const day = d.getUTCDay();
207
+ return day === 0 || day === 6;
208
+ }
209
+
210
+ function buildHolidaySet(holidays?: string[]): Set<string> {
211
+ if (!holidays?.length) {
212
+ return new Set();
213
+ }
214
+ const set = new Set<string>();
215
+ for (const h of holidays) {
216
+ const d = toDateOnly(new Date(h).getTime());
217
+ if (!isNaN(d.getTime())) {
218
+ set.add(toDateKey(d));
219
+ }
220
+ }
221
+ return set;
222
+ }
223
+
224
+ function isNonBusinessDay(d: Date, holidaySet: Set<string>): boolean {
225
+ return isWeekend(d) || (holidaySet.size > 0 && holidaySet.has(toDateKey(d)));
226
+ }
227
+
228
+ /**
229
+ * Add business days to a date (epoch ms), skipping weekends and holidays.
230
+ * Returns the resulting date as epoch ms.
231
+ */
232
+ function addBusinessDays(startMs: number, days: number, holidaySet: Set<string>): number {
233
+ const result = toDateOnly(startMs);
234
+ if (!isFinite(result.getTime())) {
235
+ return result.getTime();
236
+ }
237
+
238
+ const intDays = Math.trunc(days);
239
+ if (intDays === 0) {
240
+ return result.getTime();
241
+ }
242
+
243
+ const direction = intDays > 0 ? 1 : -1;
244
+ let remaining = Math.abs(intDays);
245
+
246
+ while (remaining > 0) {
247
+ result.setUTCDate(result.getUTCDate() + direction);
248
+ if (!isNonBusinessDay(result, holidaySet)) {
249
+ remaining--;
250
+ }
251
+ }
252
+
253
+ return result.getTime();
254
+ }
255
+
256
+ /**
257
+ * Count business days between two dates (epoch ms), skipping weekends and holidays.
258
+ * Positive when `toMs` is after `fromMs`, negative otherwise.
259
+ */
260
+ function businessDayDiff(fromMs: number, toMs: number, holidaySet: Set<string>): number {
261
+ const start = toDateOnly(fromMs);
262
+ const end = toDateOnly(toMs);
263
+
264
+ if (!isFinite(start.getTime()) || !isFinite(end.getTime())) {
265
+ return 0;
266
+ }
267
+
268
+ const direction = end.getTime() >= start.getTime() ? 1 : -1;
269
+ const cursor = new Date(start.getTime());
270
+ let count = 0;
271
+
272
+ while (toDateKey(cursor) !== toDateKey(end)) {
273
+ cursor.setUTCDate(cursor.getUTCDate() + direction);
274
+ if (!isNonBusinessDay(cursor, holidaySet)) {
275
+ count++;
276
+ }
277
+ }
278
+
279
+ return count * direction;
280
+ }
281
+
282
+ function resolveDateAwareTokens(
283
+ formula: StructuredFormula,
284
+ data: DataModelValues | undefined,
285
+ dateFieldPaths: Set<string>,
286
+ ): DateAwareToken[] {
287
+ return formula.tokens.map((t: FormulaToken): DateAwareToken => {
288
+ if (t.type === 'field') {
289
+ const raw = resolvePdfDataValues(data, t.path);
290
+ if (dateFieldPaths.has(t.path)) {
291
+ const ms = parseDateValue(raw);
292
+ return { kind: 'date', value: isNaN(ms) ? 0 : ms };
293
+ }
294
+ return { kind: 'number', value: valueToNumber(raw) };
295
+ }
296
+ if (t.type === 'number') {
297
+ return { kind: 'number', value: valueToNumber(t.value) };
298
+ }
299
+ return t as DateAwareToken;
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Apply an operator to two typed values with date-aware, business-day semantics:
305
+ * date + number → add business days → date
306
+ * number + date → add business days → date
307
+ * date - number → sub business days → date
308
+ * date - date → business day diff → number
309
+ * number ± number → normal → number
310
+ */
311
+ function applyDateOp(
312
+ a: TypedValue,
313
+ op: FormulaOperator,
314
+ b: TypedValue,
315
+ holidaySet: Set<string>,
316
+ ): TypedValue {
317
+ if (a.kind === 'date' && b.kind === 'number') {
318
+ if (op === '+') {
319
+ return { kind: 'date', value: addBusinessDays(a.value, b.value, holidaySet) };
320
+ }
321
+ if (op === '-') {
322
+ return { kind: 'date', value: addBusinessDays(a.value, -b.value, holidaySet) };
323
+ }
324
+ }
325
+ if (a.kind === 'number' && b.kind === 'date' && op === '+') {
326
+ return { kind: 'date', value: addBusinessDays(b.value, a.value, holidaySet) };
327
+ }
328
+ if (a.kind === 'date' && b.kind === 'date' && op === '-') {
329
+ return { kind: 'number', value: businessDayDiff(a.value, b.value, holidaySet) };
330
+ }
331
+ return { kind: 'number', value: applyOp(a.value, op, b.value) };
332
+ }
333
+
334
+ function evalDateFlat(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
335
+ const list = [...tokens];
336
+
337
+ for (let i = 1; i < list.length - 1; ) {
338
+ const t = list[i];
339
+ if ('type' in t && t.type === 'operator' && (t.value === '+' || t.value === '-')) {
340
+ const left = list[i - 1];
341
+ const right = list[i + 1];
342
+ if (isTypedValue(left) && isTypedValue(right)) {
343
+ const result = applyDateOp(left, t.value, right, holidaySet);
344
+ list.splice(i - 1, 3, result);
345
+ continue;
346
+ }
347
+ }
348
+ i += 2;
349
+ }
350
+
351
+ const single = list[0];
352
+ return isTypedValue(single) ? single : { kind: 'number', value: 0 };
353
+ }
354
+
355
+ function evalDateAwareTokens(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
356
+ if (tokens.length === 0) {
357
+ return { kind: 'number', value: 0 };
358
+ }
359
+ if (tokens.length === 1 && isTypedValue(tokens[0])) {
360
+ return tokens[0];
361
+ }
362
+
363
+ const list = [...tokens];
364
+
365
+ let idx = 0;
366
+ while (idx < list.length) {
367
+ if ('type' in list[idx] && (list[idx] as { type: string }).type === 'lparen') {
368
+ const close = findMatchingParen(list as { type: string }[], idx);
369
+ if (close === -1) {
370
+ break;
371
+ }
372
+ const inner = list.slice(idx + 1, close);
373
+ const result = evalDateAwareTokens(inner as DateAwareToken[], holidaySet);
374
+ list.splice(idx, close - idx + 1, result);
375
+ continue;
376
+ }
377
+ idx++;
378
+ }
379
+
380
+ return evalDateFlat(list as DateAwareToken[], holidaySet);
381
+ }
382
+
383
+ /*
384
+ * ---------------------------------------------------------------------------
385
+ * Public API
386
+ * ---------------------------------------------------------------------------
387
+ */
388
+
145
389
  /**
146
390
  * Evaluate a structured formula with the given data.
147
391
  * Field tokens are resolved via resolvePdfDataValues; string values are converted to number.
148
- * Supports +, -, *, / and parentheses. Returns 0 when the formula is empty or invalid.
392
+ * Supports +, -, *, / and parentheses.
393
+ *
394
+ * Returns `null` when the formula is empty **or** when any field token resolves
395
+ * to null / undefined / empty / non-numeric (non-date) — making the entire
396
+ * calculation blank instead of silently substituting 0.
397
+ *
398
+ * When `dateFieldPaths` is provided, date fields are resolved to epoch-ms and
399
+ * arithmetic follows business-day semantics — weekends and holidays are skipped
400
+ * when adding/subtracting days, and date diffs count only business days.
149
401
  */
150
402
  export function evaluateFormula(
151
403
  formula: StructuredFormula | undefined | null,
152
404
  data: DataModelValues | undefined,
153
- ): number {
405
+ dateFieldPaths?: Set<string>,
406
+ holidays?: string[],
407
+ ): number | null {
154
408
  if (!formula?.tokens?.length) {
155
- return 0;
409
+ return null;
410
+ }
411
+
412
+ if (!hasAllFieldValues(formula, data, dateFieldPaths)) {
413
+ return null;
156
414
  }
415
+
416
+ if (dateFieldPaths?.size) {
417
+ const resolved = resolveDateAwareTokens(formula, data, dateFieldPaths);
418
+ const holidaySet = buildHolidaySet(holidays);
419
+ const result = evalDateAwareTokens(resolved, holidaySet);
420
+ return result.value;
421
+ }
422
+
157
423
  const resolved = resolveTokens(formula, data);
158
424
  return evalResolvedTokens(resolved);
159
425
  }
@@ -48,14 +48,10 @@ export function tokenizeExpression(expression: string): ExpressionPart[] {
48
48
  }
49
49
 
50
50
  /**
51
- * Parse an expression string into FormulaToken[] using valid paths and path->label map.
52
- * Invalid or unknown identifiers are skipped (or could be left as placeholder); only known paths become field tokens.
51
+ * Parse an expression string into FormulaToken[] using valid paths.
52
+ * Invalid or unknown identifiers are skipped; only known paths become field tokens.
53
53
  */
54
- export function parseExpression(
55
- expression: string,
56
- validPaths: Set<string>,
57
- pathToLabel: Map<string, string>,
58
- ): FormulaToken[] {
54
+ export function parseExpression(expression: string, validPaths: Set<string>): FormulaToken[] {
59
55
  const parts = tokenizeExpression(expression).filter(
60
56
  p => p.type !== 'text' || p.value.trim() !== '',
61
57
  );
@@ -70,7 +66,6 @@ export function parseExpression(
70
66
  tokens.push({
71
67
  type: 'field',
72
68
  path: part.value,
73
- label: pathToLabel.get(part.value) ?? part.value,
74
69
  });
75
70
  }
76
71
  continue;