
# Date Actions

The `date` action family does timezone- and locale-aware date work inside an automation: render an instant, read one back out of a string, shift it, measure between two, and snap to a calendar boundary. Eight operators, no more — the omissions are deliberate and are listed at the end of this page.

```yaml
- name: dueLabel
  type: date
  operator: format
  props:
    input: '{{trigger.data.dueAt}}'
    pattern: "EEEE d MMMM yyyy 'at' HH:mm"
    timezone: Europe/Paris
    locale: fr-FR
```

## The Operators

| Operator   | Required props             | Optional props                  | Output                                         |
| ---------- | -------------------------- | ------------------------------- | ---------------------------------------------- |
| `format`   | `input`, `pattern`         | `timezone`, `locale`            | `{ formatted }`                                |
| `parse`    | `input`, `pattern`         | `timezone`                      | `{ instant, valid }`                           |
| `add`      | `input` + ≥1 duration part | `timezone`                      | `{ instant }`                                  |
| `subtract` | `input` + ≥1 duration part | `timezone`                      | `{ instant }`                                  |
| `diff`     | `from`, `to`, `unit`       | `timezone`                      | `{ value }`                                    |
| `startOf`  | `input`, `unit`            | `timezone`                      | `{ instant }`                                  |
| `endOf`    | `input`, `unit`            | `timezone`                      | `{ instant }`                                  |
| `now`      | _(none)_                   | `pattern`, `timezone`, `locale` | `{ instant }`, plus `formatted` with a pattern |

`instant` is always an ISO 8601 string, never a `Date`: a step output is JSON-persisted to run history, re-read by templates and returned in webhook bodies, and a `Date` survives none of those hops with its type intact.

`timezone` is an IANA identifier (`Europe/Paris`), defaulting to `UTC`. An instant carries no zone of its own — `timezone` is what the instant is _read in_. A fixed offset (`+02:00`) is accepted but cannot express DST, so prefer a named zone anywhere that observes it. `locale` is a BCP 47 tag (`fr-FR`, default `en-US`) and affects only the month and weekday **name** tokens; a purely numeric pattern ignores it.

## The Pattern Token Set Is Closed

`pattern` is written in Unicode LDML tokens, and the vocabulary is **closed**:

| Token  | Meaning                                  | Parseable |
| ------ | ---------------------------------------- | --------- |
| `yyyy` | Calendar year, 4 digits (2026)           | yes       |
| `MM`   | Month, 2 digits (01–12)                  | yes       |
| `dd`   | Day of month, 2 digits (01–31)           | yes       |
| `HH`   | Hour, 2 digits, 24-hour (00–23)          | yes       |
| `mm`   | Minute, 2 digits (00–59)                 | yes       |
| `ss`   | Second, 2 digits (00–59)                 | yes       |
| `MMMM` | Month name, full, localised (March)      | no        |
| `MMM`  | Month name, short, localised (Mar)       | no        |
| `EEEE` | Weekday name, full, localised (Saturday) | no        |
| `EEE`  | Weekday name, short, localised (Sat)     | no        |
| `YYYY` | Legacy alias of `yyyy`                   | yes       |
| `DD`   | Legacy alias of `dd`                     | yes       |

:::callout
**An unrecognised token is an error, not a pass-through.** Every ASCII letter outside a quoted literal must belong to a token in the table above — LDML's own rule, where letters are reserved. Quote literal letters: `"yyyy-MM-dd'T'HH:mm:ss"`. Non-letters (`-` `/` `:` space) are literals and pass through untouched. Closing the set is what keeps this surface finite and testable: the moment a full forty-token vocabulary is implied, all forty are owed.
:::

The four **name** tokens are marked not parseable. `parse` therefore takes no `locale`, and a pattern containing `MMMM` or `EEEE` is rejected on the parse side — `mars` is ambiguous across locales and abbreviation styles, and accepting it would be guesswork rather than parsing.

## `parse` Reports Validity as Data

```yaml
- name: readDueDate
  type: date
  operator: parse
  props:
    input: '{{trigger.data.dueDate}}'
    pattern: dd/MM/yyyy
    timezone: Europe/Paris
```

Output is `{ instant, valid }`. A string that does not match the pattern **succeeds** the step with `valid: false` and a `null` instant, rather than failing it. That is deliberate, and two behaviours depend on it:

- **Retry.** A failed step is retried per its `retry` config. Retrying a deterministic verdict burns the budget and delays the run for a result that cannot change.
- **Control flow.** A failed step stops the branch unless `continueOnError` is set, so a downstream `filter` or `path` meant to route the invalid rows would never run. Returning data keeps the decision where an author can act on it.

A malformed **pattern** is the opposite case — an author error, not data — and does fail the step.

```yaml
- name: routeInvalid
  type: filter
  operator: continue
  props:
    condition:
      conditions:
        - field: '{{readDueDate.valid}}'
          operator: equals
          value: true
    onFalse: skip
```

## Arithmetic: Calendar Units vs Elapsed Time

`add` and `subtract` take at least one duration component. The counts are plural (`days: 7`) while unit names elsewhere are singular — quantities versus names, the same split Temporal and java.time draw. Declaring none is a configuration error.

| Component | Resolved as                                                     |
| --------- | --------------------------------------------------------------- |
| `years`   | **Calendar** — DST-aware, wall clock preserved                  |
| `months`  | **Calendar** — 31 January + 1 month is 28 February              |
| `weeks`   | **Calendar** — DST-aware, wall clock preserved                  |
| `days`    | **Calendar** — a day across a DST change is 23 or 25 real hours |
| `hours`   | **Elapsed** — an hour is always 60 minutes                      |
| `minutes` | **Elapsed** — unaffected by DST                                 |
| `seconds` | **Elapsed** — unaffected by DST                                 |

:::callout
**Day-and-larger is calendar, hour-and-below is fixed.** Adding `{ days: 1 }` to noon in `Europe/Paris` across spring-forward lands on noon the next day — 23 real hours later. Adding `{ hours: 24 }` lands on 13:00 — 24 real hours later. Both are correct answers to different questions, and the split is what keeps `add: { hours: 3 }` and a subsequent `diff` in `hour` agreeing. `diff` draws the same line: `hour` and below are fixed-length and ignore the zone entirely.
:::

Larger units apply before smaller ones, so month-end clamping happens first: 31 January `+ { months: 1, hours: 6 }` clamps to 28 February and then adds six hours.

```yaml
- name: reminderAt
  type: date
  operator: subtract
  props:
    input: '{{trigger.data.dueAt}}'
    days: 2
    hours: 3
    timezone: Europe/Paris
```

## `diff` — Signed and Truncated Toward Zero

```yaml
- name: daysLate
  type: date
  operator: diff
  props:
    from: '{{trigger.data.dueAt}}'
    to: '{{now.instant}}'
    unit: day
    timezone: Europe/Paris
```

`unit` is singular: `year`, `month`, `week`, `day`, `hour`, `minute`, `second` or `millisecond`. The result is **signed** — negative when `to` precedes `from` — and truncated toward zero, so a 47-hour gap is one `day`, not two. Calendar units are counted against the zone, elapsed ones are not.

Because `diff` already answers with a sign, there are no `isBefore` / `isAfter` / `isBetween` operators; compare the result in a `filter` or `path` condition instead.

## `startOf` / `endOf` — Calendar Boundaries

```yaml
- name: monthStart
  type: date
  operator: startOf
  props:
    input: '{{trigger.data.occurredAt}}'
    unit: month
    timezone: Europe/Paris
```

`unit` is one of `year`, `month`, `week`, `day`, `hour`, `minute` or `second` — the same singular vocabulary as `diff`, minus `millisecond`, which no boundary snaps to. The boundary is computed in `timezone`, so the start of a day in `Europe/Paris` is 22:00 or 23:00 UTC the evening before, depending on the season.

## `now`

```yaml
- name: stamp
  type: date
  operator: now
  props:
    pattern: yyyy-MM-dd
    timezone: Europe/Paris
```

Returns `{ instant }` — a UTC instant — and adds `formatted` when a `pattern` is supplied. It is the one operator with no required props.

## What Is Deliberately Absent

The eight operators were chosen against a capability domain rather than against a date library's API surface, so several familiar names are missing on purpose:

- `isBefore` / `isAfter` / `isBetween` — belong in a `filter` or `path` condition; `diff` already returns a signed answer.
- `toTimezone` — an instant carries no zone, so the name teaches a wrong model. `format`'s `timezone` covers it.
- `dayOfWeek` / `isWeekday` / `isWeekend` — `format` with `EEEE`, plus a filter. Three operators for one token is the per-library sprawl this family exists to avoid.
- `timestamp` / `fromTimestamp` — epoch tokens are a token-set question, not an operator question.
- Business-day and holiday arithmetic — needs a calendar the platform does not have, and a wrong answer here is worse than no answer.

## Calling Date Operators From Code

Every operator is reachable from a `code` action as `context.actions.date.<operator>(props)`. That path does not decode props against the schema, so the handler re-applies the guards itself — an invalid timezone or an unrecognised token fails there exactly as it would at boot.

## Related Pages

- [Actions Overview](/en/docs/automation-actions-overview) — the action model and every family.
- [Data & State](/en/docs/automation-data-actions) — `filter/continue`, where a `parse` verdict is routed.
- [Flow Control](/en/docs/automation-flow-control) — `path/branch` for date-driven routing.
- [Triggers](/en/docs/automation-triggers) — the `cron` trigger, which shares this timezone vocabulary.
- [Code Actions](/en/docs/automation-code-actions) — the `context.actions.date` call path.
