Feature: Date Formula Support
Overview
Dynaforms historically only supported arithmetic on numeric or currency fields. The Date Formula feature introduces the ability to calculate dates dynamically in formula fields. This enables workflows like generating deadlines automatically (e.g., Start Date + 30 Days) or calculating duration between two dates (e.g., End Date - Start Date = Total Days).
This was a full-stack feature touching both the API engine and the UI builder, requiring custom date arithmetic, safe hydration, real-time validation, and dynamic element rendering.
High-Level Architecture Flow
1. Backend Implementation details (formula.controller.ts)
The backend engine uses the expr-eval package, which natively only understands JavaScript numbers and strings. We had to teach it to understand Date objects.
A. Hydrating the Data: hydrateDateValues()
When JSON payloads arrive over HTTP, dates are serialized as strings. If we pass these strings directly to the math parser, "2026-10-01" - "2026-09-01" results in NaN.
We implemented a Hydration phase before evaluation:
private hydrateDateValues(values: Record<string, number | string>): Record<string, number | Date> {
const revived: any = {};
for (const [key, val] of Object.entries(values)) {
if (typeof val === 'string' && this.isIsoDate(val)) {
revived[key] = new Date(val);
} else {
revived[key] = val;
}
}
return revived;
}
B. Custom Operators: configureDateOperators()
The core of the logic is overriding the + and - binary operators inside expr-eval's parser.
private addDaysToDate(date: Date, days: number): Date {
const roundedDays = Math.round(days);
return new Date(date.getTime() + roundedDays * 86400000); // 86,400,000 ms in a day
}
p.binaryOps["+"] = (a: any, b: any) => {
// Safeguard: Missing operands in date math return null
if (a == null || b == null) {
if (a instanceof Date || b instanceof Date) return null;
}
if (a instanceof Date && typeof b === "number") {
return this.addDaysToDate(a, b);
}
return originalAdd(a, b);
};
Key Architectural Decisions Here:
-
Why
a.getTime() + b * 86400000instead ofa.setDate(a.getDate() + b)? UsingsetDate()mutates the date using local timezone rules. If a calculation crosses a Daylight Saving Time (DST) boundary (e.g., Spring Forward), the local day might only have 23 hours. Adding exactly 1 "day" viasetDate()could yield a timestamp of 11:00 PM the previous night. Using.getTime()converts the date to absolute UTC milliseconds since the Unix epoch. We add exactly 24 hours of milliseconds (86400000), completely bypassing DST timezone anomalies and forcing the calculation to stay aligned on calendar days. -
Why
Math.round(b)? If a user writes@Date + 1.5, adding fractional days generates a timestamp at noon. Depending on timezone conversions and string trimming operations upstream in the front-end components, a noon timestamp might floor unexpectedly. We enforce full days explicitly to guarantee predictable date outputs. -
Bypassing the Type Checker:
expr.evaluate(revivedValues as any)We encountered a TypeScript compiler error whereexpr-evalrestricts theValuetype union to strictly primitives and objects (excludingDate). Because we safely overrode the operator handling manually to processDateinstances, we bypass this outdated ambient type declaration usingas any. -
Targeted Endpoints We explicitly removed
configureDateOperatorsfrom the/validatesyntax-checking endpoint, because that endpoint only verifies grammar and parses operators; it does not evaluate injected memory objects, meaning the custom date evaluation overrides are only needed in/evaluate. -
Null-Guards for Robustness (Fixing Garbage Values) We observed that in formulas like
@End Date - @Start Date, if a user had only filled one date, Javascript would coerce the missing field to0ornull, resulting in massive negative timestamps (e.g.,-177230340000). We implemented ternary guards to explicitly returnnullif any operand involved with aDateis missing, keeping the formula field empty until all required data is present.
2. Frontend Formula Builder (formula-builder.component.ts)
In the design phase, the formula builder has to do two complex things: block impossible math, and prepare the renderer.
A. Real-time Validation (Blocking Date + Date)
Adding two absolute dates together (e.g., Nov 5, 2026 + Jan 1, 2027) is mathematically meaningless. We must block it before the backend attempts to evaluate it.
Flexible Validation (Mixed Math Support):
Originally, we used a strict guard that blocked any formula with multiple dates and a + sign. However, this incorrectly blocked valid math like @Start Date + 10 - @End Date. We refactored the logic to focus specifically on finding invalid direct additions while allowing balanced duration math.
The catch: We couldn't just write if (dateFields.length > 1 && includes('+')). If a user typed @Date1 + @Date1 (the exact same field twice), the number of unique dateFields extracted is only 1. Furthermore, valid formulas can contain a + (for numeric offsets).
The Solution: We parse the raw user input and count the occurrences of date field mentions across the entire syntax by building dynamic regexes based correctly on their labels.
B. Type Inference: deriveIsDateOutput()
When a user finishes designing a formula, the frontend builder must label the outputType. Will this formula result in a single Date, or a raw Decimal (number of days)?
- 1 Date Reference: It's either
Date + NumberorDate - Number. Result is a Date (e.g., Deadline calculation). - 2+ Date References (subtracted): It's
Date - Date. Result is a Decimal number of days (e.g., Age / Duration calculation).
The builder detects this via occurrences and emits outputType: 'date' or outputType: 'decimal', binding it directly into the JSON configuration schema that powers the renderer.
C. Isolated Builder Context (Clean Slate Logic)
To prevent "split-brain" states where a field has conflicting top-level and rule-level configurations, we implemented Clean Slate Architecture:
- When
useRulesis enabled, the builder explicitly removes the top-leveloutputTypeandoutputCurrency. - This ensures the Formula Library becomes the single source of truth for the field's data type, preventing the renderer from using stale legacy properties.
D. Reverse Migration Support
When a user disables rules, we want a seamless transition back to legacy mode:
- Toggling
useRulestofalsetriggers an automatic Type Sync. - The builder reads the
outputTypeandoutputCurrencyfrom the currently defined Default Formula and promotes them to the top-level field configuration. - This guarantees the field remains correctly typed and functional immediately after switching back to simple mode.
3. Frontend Formula Renderer (formula-field.component.ts)
Once outputType is baked into the component configuration, the renderer uses it to dynamically paint the UI.
- Previously,
formula-field.component.htmlunconditionally rendered a<p-inputNumber>. - We added an
*ngIf="config.outputType === 'date'"branch that renders a readonly<p-datepicker>instead.
A. Hydration & Comparison Logic
Just like the API backend, the HTTP response payload returns calculated dates as ISO 8601 strings. PrimeNG's p-datepicker crashes if provided a raw string; it requires a Javascript Date object.
We import our shared utility to revive the date string:
// Shared utility (engine/formula-date.utils.ts)
export function reviveDateString(val: unknown): Date | unknown {
return typeof val === "string" && ISO_DATE_REGEX.test(val)
? new Date(val)
: val;
}
// In renderer
let val = reviveDateString(res.response);
We also updated the isSame check to strictly compare originalDate.getTime() === newDate.getTime(). A basic === equality check fails on identical Date objects because their memory references differ, which previously caused infinite reactive value-change loops in Angular forms.
B. Deterministic ID-Based Lookup
Previously, the renderer identified formula metadata by matching the raw formula string. This was prone to Ambiguity Errors if two branches of a rule had identical strings but different types.
We refactored evaluation to be deterministic:
- API Enhancement: The
/evaluateresponse now returns theuuidof the specific rule branch that was triggered. - Direct Lookup: The renderer uses this
uuidto fetch metadata from the library, ensuring that theruntimeOutputTypeexactly matches the intended branch logic, regardless of formula string similarity.
C. Robust Runtime Initialization
To ensure a "flicker-free" initial load:
- ngOnInit Sync: In
ngOnInit, if rules are active, the component immediately identifies its initialruntimeOutputTypeby inspecting the Default Formula in the config library. - Immediate Assertion: This happens before the first API call, ensuring the correct UI component (
p-datepickervsp-inputNumber) is mounted before the evaluation result arrives.
D. Value Transition Safeguards
Switching between incompatible UI components (e.g., a DatePicker becoming an InputNumber) at runtime can lead to component-level crashes if invalid data persists in the FormControl.
- Implementation: We implemented automatic value clearing (
setValue(null)) within the renderer whenever theruntimeOutputTypeswitches. - Impact: This ensures the form remains stable and prevents "garbage" data (like a raw millisecond timestamp) from being displayed in a Date field during rule transitions.
4. Shared Utilities & Rules
Code Review Improvements
During development, the ISO 8601 RegEx check /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ was duplicated 4 distinct times across different validation and evaluation files. We centralized this into a new shared frontend utility file formula-date.utils.ts to expose isIsoDateString and reviveDateString, applying the DRY (Don't Repeat Yourself) principle.
Rule Evaluator (formula-rule-evaluator.util.ts)
If a user writes a conditional logical rule like: "If Start Date > End Date, hide this field."
The existing evaluateComparison() function previously coerced values to .parseFloat(). For date ISO strings, parsing as a float shears off characters, creating unpredictable numbers. We refined the rule evaluator to:
- Date Detection: Detect Date comparisons using shared
isIsoDateStringandDateinstance checks. - Normalization: Use
.getTime()to convert both sides down to absolute integer milliseconds before arithmetic comparisons (>,<,==). - Silent Failure (Renderer Stability): If a mismatch is encountered (e.g.,
@Date > 1000), the engine returnsfalseinstead of throwing an error. This ensures the renderer stays stable while safely failing the rule logic.
Rule Validation (Proactive Builder-Side)
To compliment the silent evaluation in the renderer, we implemented strict, proactive validation in the Right Panel Builder:
- Enhanced Suggestions:
updateFormulaFieldSuggestionsnow capturesoutputTypefor all fields (including formula fields). This allows the builder to know "Field A is a Date" even if it's a calculated formula. - Unified Validation Tree: The
getRuleConditionErrormethod recursively crawls the rule logic and blocks attempts to save invalid logic:- Type Mismatches: Blocks comparing a Date field to a Number field or a static Number value.
- Incomplete Config: Blocks saving rules with empty fields or values.
- Feedback: Users receive immediate feedback via toast notifications ("Cannot save rule") at design-time, essentially preventing "broken" rules from ever reaching the end-user's renderer.
Proactive Formula Configuration Guards (Currency & Number)
To maintain the integrity of existing formulas and rules, we implemented blocking guards in the Right Panel Builder that prevent destructive configuration changes to fields already in use:
- Number Type Guard: Prevents switching a field's
numberTypeto/fromPercentageorCurrencyif it is referenced in any formulas. - Currency Code Guard: Prevents changing the
currencycode (e.g., fromINRtoUSD) for fields already used in formulas.
Verification
- Date + Number: Validated via API that passing
Date+ 5 returns aDateObject +5 days exact. - Date - Date: Validated via API that submitting two dates explicitly subtracted (
End - Start) yields an exact numericdaycoefficient. - Frontend Runtime: Tested formula builder correctly blocks
@Date + @Date, successfully resolves@Date - @Datetodecimaloutput type, rendering an<p-inputNumber>. Formula@Date + 5emitsdateoutput type, correctly rendering a<p-datepicker>. - DST Logic: Tested locally that
setDatetimezone mutations are completely avoided by using the strict UTC milligetTime() + mslogic, ensuring fractional day roll-overs are impossible. - Rule Proactivity: Verified that the Right Panel correctly blocks saving mixed-type rules (Date vs Number) and provides descriptive toast notifications.
- Configuration Guards: Confirmed that the Right Panel blocks changing
numberTypeorcurrencycode for any field already referenced in formulas, correctly reverting the UI state and showing "Action Prevented" toasts.