MD.OFFICE
FAL

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:

  1. Why a.getTime() + b * 86400000 instead of a.setDate(a.getDate() + b)? Using setDate() 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" via setDate() 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.

  2. 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.

  3. Bypassing the Type Checker: expr.evaluate(revivedValues as any) We encountered a TypeScript compiler error where expr-eval restricts the Value type union to strictly primitives and objects (excluding Date). Because we safely overrode the operator handling manually to process Date instances, we bypass this outdated ambient type declaration using as any.

  4. Targeted Endpoints We explicitly removed configureDateOperators from the /validate syntax-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.

  5. 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 to 0 or null, resulting in massive negative timestamps (e.g., -177230340000). We implemented ternary guards to explicitly return null if any operand involved with a Date is 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 + Number or Date - 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 useRules is enabled, the builder explicitly removes the top-level outputType and outputCurrency.
  • 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 useRules to false triggers an automatic Type Sync.
  • The builder reads the outputType and outputCurrency from 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.html unconditionally 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:

  1. API Enhancement: The /evaluate response now returns the uuid of the specific rule branch that was triggered.
  2. Direct Lookup: The renderer uses this uuid to fetch metadata from the library, ensuring that the runtimeOutputType exactly 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 initial runtimeOutputType by inspecting the Default Formula in the config library.
  • Immediate Assertion: This happens before the first API call, ensuring the correct UI component (p-datepicker vs p-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 the runtimeOutputType switches.
  • 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:

  1. Date Detection: Detect Date comparisons using shared isIsoDateString and Date instance checks.
  2. Normalization: Use .getTime() to convert both sides down to absolute integer milliseconds before arithmetic comparisons (>, <, ==).
  3. Silent Failure (Renderer Stability): If a mismatch is encountered (e.g., @Date > 1000), the engine returns false instead 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: updateFormulaFieldSuggestions now captures outputType for 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 getRuleConditionError method 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:

  1. Number Type Guard: Prevents switching a field's numberType to/from Percentage or Currency if it is referenced in any formulas.
  2. Currency Code Guard: Prevents changing the currency code (e.g., from INR to USD) for fields already used in formulas.

Verification

  1. Date + Number: Validated via API that passing Date + 5 returns a Date Object +5 days exact.
  2. Date - Date: Validated via API that submitting two dates explicitly subtracted (End - Start) yields an exact numeric day coefficient.
  3. Frontend Runtime: Tested formula builder correctly blocks @Date + @Date, successfully resolves @Date - @Date to decimal output type, rendering an <p-inputNumber>. Formula @Date + 5 emits date output type, correctly rendering a <p-datepicker>.
  4. DST Logic: Tested locally that setDate timezone mutations are completely avoided by using the strict UTC milli getTime() + ms logic, ensuring fractional day roll-overs are impossible.
  5. Rule Proactivity: Verified that the Right Panel correctly blocks saving mixed-type rules (Date vs Number) and provides descriptive toast notifications.
  6. Configuration Guards: Confirmed that the Right Panel blocks changing numberType or currency code for any field already referenced in formulas, correctly reverting the UI state and showing "Action Prevented" toasts.