@qrvey/formula-lang 3.2.0-rc.1109 → 3.2.0-rc.1159

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 (219) hide show
  1. package/QRVEY-DATE-PRESETS.md +630 -69
  2. package/date-preset-qdp-examples.csv +118 -0
  3. package/dist/cjs/constants/index.d.ts +29 -1
  4. package/dist/cjs/constants/index.js +33 -1
  5. package/dist/cjs/constants/index.js.map +1 -1
  6. package/dist/cjs/constants/interfaces.d.ts +73 -4
  7. package/dist/cjs/date-presets/date-preset-classification.d.ts +41 -0
  8. package/dist/cjs/date-presets/date-preset-classification.js +183 -0
  9. package/dist/cjs/date-presets/date-preset-classification.js.map +1 -0
  10. package/dist/cjs/date-presets/date-preset-matches.d.ts +30 -0
  11. package/dist/cjs/date-presets/date-preset-matches.js +86 -0
  12. package/dist/cjs/date-presets/date-preset-matches.js.map +1 -0
  13. package/dist/cjs/date-presets/date-preset-tokens.d.ts +20 -8
  14. package/dist/cjs/date-presets/date-preset-tokens.js +36 -10
  15. package/dist/cjs/date-presets/date-preset-tokens.js.map +1 -1
  16. package/dist/cjs/date-presets/date-presets.d.ts +18 -1
  17. package/dist/cjs/date-presets/date-presets.js +99 -178
  18. package/dist/cjs/date-presets/date-presets.js.map +1 -1
  19. package/dist/cjs/date-presets/index.d.ts +3 -1
  20. package/dist/cjs/date-presets/index.js +9 -1
  21. package/dist/cjs/date-presets/index.js.map +1 -1
  22. package/dist/cjs/errors/dictionary.d.ts +6 -1
  23. package/dist/cjs/errors/dictionary.js +25 -0
  24. package/dist/cjs/errors/dictionary.js.map +1 -1
  25. package/dist/cjs/functions/calendarPeriod.js +6 -10
  26. package/dist/cjs/functions/calendarPeriod.js.map +1 -1
  27. package/dist/cjs/functions/dateRange.js +5 -2
  28. package/dist/cjs/functions/dateRange.js.map +1 -1
  29. package/dist/cjs/functions/endOf.js +16 -2
  30. package/dist/cjs/functions/endOf.js.map +1 -1
  31. package/dist/cjs/functions/field.d.ts +5 -0
  32. package/dist/cjs/functions/field.js +52 -0
  33. package/dist/cjs/functions/field.js.map +1 -0
  34. package/dist/cjs/functions/format.d.ts +2 -0
  35. package/dist/cjs/functions/format.js +281 -0
  36. package/dist/cjs/functions/format.js.map +1 -0
  37. package/dist/cjs/functions/index.d.ts +5 -0
  38. package/dist/cjs/functions/index.js +36 -1
  39. package/dist/cjs/functions/index.js.map +1 -1
  40. package/dist/cjs/functions/offset.d.ts +2 -0
  41. package/dist/cjs/functions/offset.js +57 -0
  42. package/dist/cjs/functions/offset.js.map +1 -0
  43. package/dist/cjs/functions/part.js +19 -3
  44. package/dist/cjs/functions/part.js.map +1 -1
  45. package/dist/cjs/functions/partialDate.js +53 -4
  46. package/dist/cjs/functions/partialDate.js.map +1 -1
  47. package/dist/cjs/functions/periodAt.js +5 -9
  48. package/dist/cjs/functions/periodAt.js.map +1 -1
  49. package/dist/cjs/functions/priorPeriod.d.ts +2 -0
  50. package/dist/cjs/functions/priorPeriod.js +46 -0
  51. package/dist/cjs/functions/priorPeriod.js.map +1 -0
  52. package/dist/cjs/functions/relativePeriod.js +6 -10
  53. package/dist/cjs/functions/relativePeriod.js.map +1 -1
  54. package/dist/cjs/functions/samePeriod.d.ts +2 -0
  55. package/dist/cjs/functions/samePeriod.js +57 -0
  56. package/dist/cjs/functions/samePeriod.js.map +1 -0
  57. package/dist/cjs/functions/startOf.js +16 -2
  58. package/dist/cjs/functions/startOf.js.map +1 -1
  59. package/dist/cjs/functions/toDate.d.ts +2 -0
  60. package/dist/cjs/functions/toDate.js +46 -0
  61. package/dist/cjs/functions/toDate.js.map +1 -0
  62. package/dist/cjs/functions/today.js +4 -2
  63. package/dist/cjs/functions/today.js.map +1 -1
  64. package/dist/cjs/grammar/generated/qformula.lang.js +8 -8
  65. package/dist/cjs/grammar/generated/qformula.lang.js.map +1 -1
  66. package/dist/cjs/grammar/generated/qformula.lang.terms.d.ts +1 -0
  67. package/dist/cjs/grammar/generated/qformula.lang.terms.js +2 -2
  68. package/dist/cjs/grammar/generated/qformula.lang.terms.js.map +1 -1
  69. package/dist/cjs/index.d.ts +2 -2
  70. package/dist/cjs/index.js +7 -1
  71. package/dist/cjs/index.js.map +1 -1
  72. package/dist/cjs/parser/formula-parser.js +18 -1
  73. package/dist/cjs/parser/formula-parser.js.map +1 -1
  74. package/dist/cjs/parser/json-parser.js +22 -23
  75. package/dist/cjs/parser/json-parser.js.map +1 -1
  76. package/dist/cjs/transpiler/columnTranspilation.js +15 -0
  77. package/dist/cjs/transpiler/columnTranspilation.js.map +1 -1
  78. package/dist/cjs/transpiler/index.js +5 -3
  79. package/dist/cjs/transpiler/index.js.map +1 -1
  80. package/dist/cjs/transpiler/qdpArithmetic.d.ts +15 -0
  81. package/dist/cjs/transpiler/qdpArithmetic.js +69 -0
  82. package/dist/cjs/transpiler/qdpArithmetic.js.map +1 -0
  83. package/dist/cjs/utils/alignedUnit.d.ts +14 -0
  84. package/dist/cjs/utils/alignedUnit.js +43 -0
  85. package/dist/cjs/utils/alignedUnit.js.map +1 -0
  86. package/dist/cjs/utils/datePresetFormat.d.ts +44 -0
  87. package/dist/cjs/utils/datePresetFormat.js +222 -0
  88. package/dist/cjs/utils/datePresetFormat.js.map +1 -0
  89. package/dist/cjs/utils/datePresetUtils.d.ts +63 -4
  90. package/dist/cjs/utils/datePresetUtils.js +192 -35
  91. package/dist/cjs/utils/datePresetUtils.js.map +1 -1
  92. package/dist/cjs/utils/escapeCharacters.js +2 -2
  93. package/dist/cjs/utils/escapeCharacters.js.map +1 -1
  94. package/dist/cjs/utils/extractContextInfo.d.ts +41 -0
  95. package/dist/cjs/utils/extractContextInfo.js +52 -0
  96. package/dist/cjs/utils/extractContextInfo.js.map +1 -0
  97. package/dist/cjs/utils/index.d.ts +1 -1
  98. package/dist/cjs/utils/index.js +2 -3
  99. package/dist/cjs/utils/index.js.map +1 -1
  100. package/dist/cjs/utils/isInteger.js +17 -4
  101. package/dist/cjs/utils/isInteger.js.map +1 -1
  102. package/dist/cjs/utils/removeQuotes.d.ts +8 -0
  103. package/dist/cjs/utils/removeQuotes.js +13 -0
  104. package/dist/cjs/utils/removeQuotes.js.map +1 -0
  105. package/dist/module/constants/index.d.ts +29 -1
  106. package/dist/module/constants/index.js +32 -0
  107. package/dist/module/constants/index.js.map +1 -1
  108. package/dist/module/constants/interfaces.d.ts +73 -4
  109. package/dist/module/date-presets/date-preset-classification.d.ts +41 -0
  110. package/dist/module/date-presets/date-preset-classification.js +179 -0
  111. package/dist/module/date-presets/date-preset-classification.js.map +1 -0
  112. package/dist/module/date-presets/date-preset-matches.d.ts +30 -0
  113. package/dist/module/date-presets/date-preset-matches.js +81 -0
  114. package/dist/module/date-presets/date-preset-matches.js.map +1 -0
  115. package/dist/module/date-presets/date-preset-tokens.d.ts +20 -8
  116. package/dist/module/date-presets/date-preset-tokens.js +36 -10
  117. package/dist/module/date-presets/date-preset-tokens.js.map +1 -1
  118. package/dist/module/date-presets/date-presets.d.ts +18 -1
  119. package/dist/module/date-presets/date-presets.js +95 -176
  120. package/dist/module/date-presets/date-presets.js.map +1 -1
  121. package/dist/module/date-presets/index.d.ts +3 -1
  122. package/dist/module/date-presets/index.js +3 -1
  123. package/dist/module/date-presets/index.js.map +1 -1
  124. package/dist/module/errors/dictionary.d.ts +6 -1
  125. package/dist/module/errors/dictionary.js +25 -0
  126. package/dist/module/errors/dictionary.js.map +1 -1
  127. package/dist/module/functions/calendarPeriod.js +7 -11
  128. package/dist/module/functions/calendarPeriod.js.map +1 -1
  129. package/dist/module/functions/dateRange.js +5 -2
  130. package/dist/module/functions/dateRange.js.map +1 -1
  131. package/dist/module/functions/endOf.js +17 -3
  132. package/dist/module/functions/endOf.js.map +1 -1
  133. package/dist/module/functions/field.d.ts +5 -0
  134. package/dist/module/functions/field.js +49 -0
  135. package/dist/module/functions/field.js.map +1 -0
  136. package/dist/module/functions/format.d.ts +2 -0
  137. package/dist/module/functions/format.js +278 -0
  138. package/dist/module/functions/format.js.map +1 -0
  139. package/dist/module/functions/index.d.ts +5 -0
  140. package/dist/module/functions/index.js +36 -1
  141. package/dist/module/functions/index.js.map +1 -1
  142. package/dist/module/functions/offset.d.ts +2 -0
  143. package/dist/module/functions/offset.js +54 -0
  144. package/dist/module/functions/offset.js.map +1 -0
  145. package/dist/module/functions/part.js +19 -3
  146. package/dist/module/functions/part.js.map +1 -1
  147. package/dist/module/functions/partialDate.js +53 -4
  148. package/dist/module/functions/partialDate.js.map +1 -1
  149. package/dist/module/functions/periodAt.js +5 -9
  150. package/dist/module/functions/periodAt.js.map +1 -1
  151. package/dist/module/functions/priorPeriod.d.ts +2 -0
  152. package/dist/module/functions/priorPeriod.js +43 -0
  153. package/dist/module/functions/priorPeriod.js.map +1 -0
  154. package/dist/module/functions/relativePeriod.js +7 -11
  155. package/dist/module/functions/relativePeriod.js.map +1 -1
  156. package/dist/module/functions/samePeriod.d.ts +2 -0
  157. package/dist/module/functions/samePeriod.js +54 -0
  158. package/dist/module/functions/samePeriod.js.map +1 -0
  159. package/dist/module/functions/startOf.js +17 -3
  160. package/dist/module/functions/startOf.js.map +1 -1
  161. package/dist/module/functions/toDate.d.ts +2 -0
  162. package/dist/module/functions/toDate.js +43 -0
  163. package/dist/module/functions/toDate.js.map +1 -0
  164. package/dist/module/functions/today.js +4 -2
  165. package/dist/module/functions/today.js.map +1 -1
  166. package/dist/module/grammar/generated/qformula.lang.js +8 -8
  167. package/dist/module/grammar/generated/qformula.lang.js.map +1 -1
  168. package/dist/module/grammar/generated/qformula.lang.terms.d.ts +1 -0
  169. package/dist/module/grammar/generated/qformula.lang.terms.js +1 -1
  170. package/dist/module/grammar/generated/qformula.lang.terms.js.map +1 -1
  171. package/dist/module/index.d.ts +2 -2
  172. package/dist/module/index.js +1 -1
  173. package/dist/module/index.js.map +1 -1
  174. package/dist/module/parser/formula-parser.js +18 -1
  175. package/dist/module/parser/formula-parser.js.map +1 -1
  176. package/dist/module/parser/json-parser.js +22 -23
  177. package/dist/module/parser/json-parser.js.map +1 -1
  178. package/dist/module/transpiler/columnTranspilation.js +15 -0
  179. package/dist/module/transpiler/columnTranspilation.js.map +1 -1
  180. package/dist/module/transpiler/index.js +5 -3
  181. package/dist/module/transpiler/index.js.map +1 -1
  182. package/dist/module/transpiler/qdpArithmetic.d.ts +15 -0
  183. package/dist/module/transpiler/qdpArithmetic.js +65 -0
  184. package/dist/module/transpiler/qdpArithmetic.js.map +1 -0
  185. package/dist/module/utils/alignedUnit.d.ts +14 -0
  186. package/dist/module/utils/alignedUnit.js +39 -0
  187. package/dist/module/utils/alignedUnit.js.map +1 -0
  188. package/dist/module/utils/datePresetFormat.d.ts +44 -0
  189. package/dist/module/utils/datePresetFormat.js +214 -0
  190. package/dist/module/utils/datePresetFormat.js.map +1 -0
  191. package/dist/module/utils/datePresetUtils.d.ts +63 -4
  192. package/dist/module/utils/datePresetUtils.js +188 -35
  193. package/dist/module/utils/datePresetUtils.js.map +1 -1
  194. package/dist/module/utils/escapeCharacters.js +2 -2
  195. package/dist/module/utils/escapeCharacters.js.map +1 -1
  196. package/dist/module/utils/extractContextInfo.d.ts +41 -0
  197. package/dist/module/utils/extractContextInfo.js +46 -0
  198. package/dist/module/utils/extractContextInfo.js.map +1 -0
  199. package/dist/module/utils/index.d.ts +1 -1
  200. package/dist/module/utils/index.js +1 -1
  201. package/dist/module/utils/index.js.map +1 -1
  202. package/dist/module/utils/isInteger.js +17 -4
  203. package/dist/module/utils/isInteger.js.map +1 -1
  204. package/dist/module/utils/removeQuotes.d.ts +8 -0
  205. package/dist/module/utils/removeQuotes.js +9 -0
  206. package/dist/module/utils/removeQuotes.js.map +1 -0
  207. package/docs/QRV-1069-analysis.md +458 -0
  208. package/docs/QRV-1069-jira-comment.md +35 -0
  209. package/docs/TECH-DEBT.md +351 -0
  210. package/package.json +5 -2
  211. package/scripts/date-preset-examples.data.js +269 -0
  212. package/scripts/generate-date-preset-examples.js +219 -0
  213. package/specs/QRV-1069--date-expression-framework/QRV-1069--additive-functions/input.md +51 -0
  214. package/specs/QRV-1069--date-expression-framework/QRV-1069--classification/input.md +51 -0
  215. package/specs/QRV-1069--date-expression-framework/QRV-1069--column-bound/input.md +89 -0
  216. package/specs/QRV-1069--date-expression-framework/QRV-1069--format/input.md +83 -0
  217. package/specs/QRV-1069--date-expression-framework/QRV-1069--scalar-results/input.md +55 -0
  218. package/specs/QRV-1069--date-expression-framework/README.md +53 -0
  219. package/specs/QRV-1069--date-expression-framework/constitution.md +118 -0
@@ -35,7 +35,11 @@ Formats dates, ranges, and partial dates for UI.
35
35
  ## Output Types
36
36
 
37
37
  ```ts
38
- type DatePresetValue = string | DateRangeValue | PartialDateValue;
38
+ type DatePresetValue =
39
+ | string
40
+ | DateRangeValue
41
+ | PartialDateValue
42
+ | ScalarValue;
39
43
  ```
40
44
 
41
45
  By default, `date` is represented as a UTC ISO string:
@@ -74,16 +78,51 @@ Supported fields for `partialDate`:
74
78
 
75
79
  ## Value Types
76
80
 
77
- `TranspileDatePreset` returns `valueType` to describe the nature of the value.
78
-
79
- | Value type | Meaning | Examples |
80
- | ----------- | ------------------------------------------------------------- | -------------------------------------------------------------------- |
81
- | `fixed` | Fixed value or materialized range | `DATE("2026-07-20")`, `DATE_RANGE(...)` |
82
- | `recurring` | Partial pattern that repeats | `PARTIAL_DATE("ANY", 4)`, `PARTIAL_DATE("ANY", 4, "ANY", "ANY", 15)` |
83
- | `relative` | Period relative to the calendar or positioned inside a period | `CALENDAR_PERIOD("MONTH", 0)`, `PERIOD_AT("MONTH", 4, 2026)` |
84
- | `rolling` | Moving window from an anchor | `RELATIVE_PERIOD(-29, "DAY")` |
85
-
86
- Current note: `PERIOD_AT("MONTH", 4, 2026)` produces a concrete range, but it is classified as `relative` because the root expression is `PERIOD_AT`. If the business goal requires distinguishing `April 2026` as `fixed`, that would be a classification improvement, not a resolution change.
81
+ `TranspileDatePreset` classifies every expression on two independent axes:
82
+ `valueType` answers *how the value was built*, `temporality` answers *whether it
83
+ moves with the clock*. They are independent — `PERIOD_AT("MONTH", 4, 2026)` is
84
+ `aligned` and `static`, while `PERIOD_AT("MONTH", 4)` is `aligned` and `dynamic`.
85
+
86
+ | Value type | Meaning | Examples |
87
+ | ----------- | ---------------------------------------------------------- | -------------------------------------------------------------------- |
88
+ | `explicit` | A named instant or a range between two of them | `DATE("2026-07-20")`, `TODAY()`, `DATE_RANGE(...)` |
89
+ | `aligned` | Snapped to a calendar boundary, or positioned in a period | `CALENDAR_PERIOD("MONTH", 0)`, `PERIOD_AT("MONTH", 4, 2026)` |
90
+ | `rolling` | Moving window or shift measured from an anchor | `RELATIVE_PERIOD(-29, "DAY")`, `OFFSET(TODAY(), -7, "DAY")` |
91
+ | `recurring` | Partial pattern that repeats | `PARTIAL_DATE("ANY", 4)`, `PARTIAL_DATE("ANY", 4, "ANY", "ANY", 15)` |
92
+ | `scalar` | A number extracted from a date | `PART(TODAY(), "MONTH")`, `PART(TODAY(), "YEAR") - 1` |
93
+ | `dataBound` | Resolved from a column value | reserved; see QRV-1069 column-bound functions |
94
+
95
+ | Temporality | Meaning | Examples |
96
+ | ----------- | ---------------------------------------------------------------- | ---------------------------------------------- |
97
+ | `static` | Resolves to the same value on every run | `DATE("2026-07-20")`, `PERIOD_AT("MONTH", 4, 2026)` |
98
+ | `dynamic` | Depends on the current instant, directly or through a default | `TODAY()`, `CALENDAR_PERIOD("MONTH", 0)` |
99
+ | `timeless` | Has no instant to move — a pattern rather than a point | `PARTIAL_DATE("ANY", 4)` |
100
+
101
+ `temporality` is optional in the response type; an absent value means `timeless`.
102
+
103
+ Both axes are structural: they are derived from the expression tree alone. The
104
+ resolution context — timezone, calendar, `weekStartsOn`, `fiscalYearStartMonth`,
105
+ `applyTimezoneToExpression` — changes resolved values, never classification.
106
+
107
+ `START_OF` and `END_OF` are boundary selectors: they take the `valueType` of
108
+ their operand. `START_OF(CALENDAR_PERIOD("MONTH", 0))` is `aligned`,
109
+ `START_OF(RELATIVE_PERIOD(-6, "DAY"))` is `rolling`, and
110
+ `START_OF(DATE("2026-07-08"))` is `explicit`.
111
+
112
+ Their optional `UNIT` does not change that. Snapping to a boundary is not what
113
+ makes a value `aligned` — `START_OF(DATE("2026-07-08"))` already snaps to a day
114
+ boundary and stays `explicit`. The unit chooses *which* boundary; it does not
115
+ change how the value was built. So `START_OF(TODAY(), "MONTH")` is `explicit`
116
+ even though it resolves to the same instant as the `aligned`
117
+ `START_OF(CALENDAR_PERIOD("MONTH", 0))`.
118
+
119
+ A now-bearing argument that is omitted defaults to now, which makes the call
120
+ `dynamic`: the year of `PERIOD_AT` and the anchor of `RELATIVE_PERIOD` both
121
+ behave that way.
122
+
123
+ The table that drives all of this is exported as
124
+ `DATE_PRESET_CLASSIFICATION_SLOTS`, one row per function, in
125
+ `src/date-presets/date-preset-classification.ts`.
87
126
 
88
127
  ## Resolution Context
89
128
 
@@ -99,6 +138,7 @@ QDP functions can receive `FormulaContext` with date preset configuration:
99
138
  fiscalYearStartDay: 1,
100
139
  weekStartsOn: 0,
101
140
  applyTimezoneToExpression: "default",
141
+ columnValues: { shipping_date: "2026-03-14T09:20:00.000Z" },
102
142
  },
103
143
  }
104
144
  ```
@@ -115,6 +155,7 @@ Current defaults:
115
155
  | `fiscalYearStartDay` | `1` | Clamped to 1-31 and adjusted to the last valid day of the month |
116
156
  | `weekStartsOn` | `0` | Sunday. Accepts 0-6 or English weekday names |
117
157
  | `applyTimezoneToExpression` | `default` | Controls the timezone format used in the returned public `expression` value |
158
+ | `columnValues` | `{}` | The value of each column for the row being resolved, keyed by column id. Read by `FIELD` |
118
159
 
119
160
  Use `timezone.timeZone` for IANA identifiers. Use `timezone.offset` for admin/default/browser/custom offset values such as `{ offset: "default" }`, `{ offset: "browser" }`, or `{ offset: "-05:00" }`.
120
161
 
@@ -176,7 +217,7 @@ NOW();
176
217
 
177
218
  Output: `date`.
178
219
 
179
- Value type when used as the final result: `fixed`.
220
+ Value type when used as the final result: `explicit`, `dynamic`.
180
221
 
181
222
  ### `TODAY()`
182
223
 
@@ -189,7 +230,7 @@ TODAY();
189
230
 
190
231
  Output: `date`.
191
232
 
192
- Value type when used as the final result: `fixed`.
233
+ Value type when used as the final result: `explicit`, `dynamic`.
193
234
 
194
235
  ### `DATE(value)`
195
236
 
@@ -223,7 +264,7 @@ Rules:
223
264
 
224
265
  Output: `date`.
225
266
 
226
- Value type: `fixed`.
267
+ Value type: `explicit`, `static`.
227
268
 
228
269
  ### `DATE_RANGE(start, end)`
229
270
 
@@ -248,7 +289,7 @@ Rules:
248
289
 
249
290
  Output: `dateRange`.
250
291
 
251
- Value type: `fixed`.
292
+ Value type: `explicit`. Temporality is derived from `START` and `END`.
252
293
 
253
294
  ### `RELATIVE_PERIOD(offset, unit, anchor?)`
254
295
 
@@ -282,7 +323,61 @@ RELATIVE_PERIOD(-2, "WEEK", DATE("2026-07-08T15:30"));
282
323
 
283
324
  Output: `dateRange`.
284
325
 
285
- Value type: `rolling`.
326
+ Value type: `rolling`. Temporality is derived from the anchor, and is `dynamic`
327
+ when the anchor is omitted.
328
+
329
+ ### `OFFSET(point, offset, unit, mode?)`
330
+
331
+ Shifts a single instant, keeping its time of day.
332
+
333
+ ```ts
334
+ // Reference date 2026-07-22.
335
+ OFFSET(TODAY(), -7, "DAY");
336
+ // 2026-07-15T00:00:00.000Z
337
+
338
+ OFFSET(DATE("2026-07-15T14:35:27"), 1, "MONTH");
339
+ // 2026-08-15T14:35:27.000Z
340
+
341
+ OFFSET(DATE("2025-01-31"), 1, "MONTH");
342
+ // 2025-02-28T00:00:00.000Z -- clamped, February has no 31st
343
+ ```
344
+
345
+ Parameters:
346
+
347
+ | Parameter | Type | Required | Notes |
348
+ | --------- | ---------------- | -------- | ----------------------------------------- |
349
+ | `POINT` | `date` | Yes | A single instant, not a range |
350
+ | `OFFSET` | Integer `number` | Yes | Negative, zero, or positive |
351
+ | `UNIT` | `string` | Yes | `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` |
352
+ | `OVERFLOW_MODE` | `string` | No | `CLAMP` (default) or `OVERFLOW` |
353
+
354
+ Rules:
355
+
356
+ - `POINT` must be a `date`. A `dateRange` is rejected rather than silently
357
+ reduced to one of its edges — `START_OF` and `END_OF` exist so the author
358
+ says which edge is meant.
359
+ - The shift is on the local wall clock of the resolution timezone, so a day
360
+ is a calendar day rather than 24 hours.
361
+ - **`OVERFLOW_MODE` decides what happens when a shift by whole months lands on a day
362
+ the target month does not have.** `CLAMP`, the default, pins it to the last
363
+ day that exists: `OFFSET(DATE("2025-01-31"), 1, "MONTH")` is `2025-02-28`.
364
+ `OVERFLOW` lets it spill: the same call with `"OVERFLOW"` is `2025-03-03`.
365
+ `OVERFLOW_MODE` has no effect on `DAY` or `WEEK`, which cannot overflow a month.
366
+ - `OVERFLOW` is the spelling that reproduces `RELATIVE_PERIOD`, which has no
367
+ mode of its own and always spills. That divergence is deliberate: the
368
+ default should be the answer a reader expects, and the other one stays
369
+ reachable by name.
370
+ - Clamping is not reversible. `OFFSET(OFFSET(DATE("2025-01-31"), 1, "MONTH"), -1, "MONTH")`
371
+ is `2025-01-28`, not `2025-01-31`; once a day is clamped away it is gone.
372
+ - Under a timezone the instant is rebuilt from its wall-clock parts, which
373
+ does not carry milliseconds. In UTC they survive.
374
+
375
+ Output: `date`.
376
+
377
+ Value type: `rolling`, always. A shift from an anchor is rolling whether or not
378
+ the anchor itself moves, so it is never delegated to `POINT`. Temporality is
379
+ derived from `POINT`: `OFFSET(TODAY(), -7, "DAY")` is `dynamic`,
380
+ `OFFSET(DATE("2026-01-01"), -7, "DAY")` is `static`.
286
381
 
287
382
  ### `CALENDAR_PERIOD(period, offset?)`
288
383
 
@@ -319,7 +414,47 @@ CALENDAR_PERIOD("QUARTER", -1);
319
414
 
320
415
  Output: `dateRange`.
321
416
 
322
- Value type: `relative`.
417
+ Value type: `aligned`, `dynamic`.
418
+
419
+ ### `TO_DATE(unit)`
420
+
421
+ The elapsed part of the current period: from the start of the `UNIT` containing
422
+ today, to the end of today.
423
+
424
+ ```ts
425
+ // Reference date 2026-07-22.
426
+ TO_DATE("MONTH");
427
+ // 2026-07-01T00:00:00.000Z -> 2026-07-22T23:59:59.999Z
428
+
429
+ TO_DATE("YEAR");
430
+ // 2026-01-01T00:00:00.000Z -> 2026-07-22T23:59:59.999Z
431
+ ```
432
+
433
+ Parameter:
434
+
435
+ | Parameter | Type | Required | Notes |
436
+ | ------------------ | --------- | -------- | ---------------------------------- |
437
+ | `UNIT` | `string` | Yes | `WEEK`, `MONTH`, `QUARTER`, `YEAR` |
438
+ | `ANCHOR_INCLUSIVE` | `boolean` | No | `true` by default |
439
+
440
+ Rules:
441
+
442
+ - The range **covers today in full** by default. Ending it at `TODAY()` would
443
+ admit a single instant, `00:00:00.000`, and drop every row stamped later.
444
+ - `ANCHOR_INCLUSIVE: false` stops the range at the end of yesterday, so today
445
+ is left out entirely: `TO_DATE("MONTH", false)` at reference date
446
+ 2026-07-22 is `2026-07-01T00:00:00.000Z -> 2026-07-21T23:59:59.999Z`.
447
+ - The start is the one `CALENDAR_PERIOD` produces for the same unit, so the
448
+ calendar, timezone, `weekStartsOn` and `fiscalYearStartMonth` of the
449
+ resolution context all apply. Under `corporate-fiscal` with
450
+ `fiscalYearStartMonth: 4`, `TO_DATE("YEAR")` starts on 1 April.
451
+ - `DAY` is not accepted. `TO_DATE("DAY")` would be the whole of today, which
452
+ `CALENDAR_PERIOD("DAY", 0)` already spells.
453
+
454
+ Output: `dateRange`.
455
+
456
+ Value type: `aligned`, always `dynamic`. Both endpoints are read from the clock,
457
+ so there is no argument for the temporality to be derived from.
323
458
 
324
459
  ### `PERIOD_AT(period, position, year?)`
325
460
 
@@ -360,7 +495,8 @@ Week rules:
360
495
 
361
496
  Output: `dateRange`.
362
497
 
363
- Current value type: `relative`.
498
+ Value type: `aligned`. Temporality is derived from `YEAR`, and is `dynamic` when
499
+ `YEAR` is omitted, because it then defaults to the current year.
364
500
 
365
501
  ### `PARTIAL_DATE(year?, month?, quarter?, week?, day?)`
366
502
 
@@ -409,15 +545,27 @@ PARTIAL_DATE("ANY", "ANY", "ANY", "ANY", 15);
409
545
  // Short format: Day 15
410
546
  ```
411
547
 
412
- Value type:
548
+ A pattern that no date could satisfy is rejected where it is written, rather
549
+ than resolving and then matching nothing:
550
+
551
+ - The month must fall inside the quarter. `PARTIAL_DATE("ANY", 5, 3)` — May in
552
+ Q3 — is an error.
553
+ - The day must exist in the month. `PARTIAL_DATE("ANY", 2, "ANY", "ANY", 30)`
554
+ is an error, and so is `PARTIAL_DATE(2026, 2, "ANY", "ANY", 29)`, since 2026
555
+ is not a leap year. Without a year, 29 February is accepted: the pattern
556
+ matches any year, and leap years exist.
557
+
558
+ A quarter and a day never contradict, because every quarter contains a 31-day
559
+ month, and a week can fall in any month. Neither pair is checked.
413
560
 
414
- - `fixed` if it includes `year`, `month`, and `day`, without `week`.
415
- - `relative` if it includes `week`.
416
- - `recurring` for all other partial dates.
561
+ Value type, always with temporality `timeless`:
417
562
 
418
- ### `START_OF(value)`
563
+ - `explicit` if it includes `year`, `month`, and `day`, without `week`.
564
+ - `recurring` for every other partial date, `week` included.
419
565
 
420
- Returns the start of a `date` or the `start` of a `dateRange`.
566
+ ### `START_OF(value, unit?)`
567
+
568
+ Returns the start of `VALUE`, or the start of the `UNIT` period containing it.
421
569
 
422
570
  ```ts
423
571
  START_OF(DATE("2026-07-08T15:30"));
@@ -425,21 +573,45 @@ START_OF(DATE("2026-07-08T15:30"));
425
573
 
426
574
  START_OF(CALENDAR_PERIOD("MONTH", -1));
427
575
  // 2026-06-01T00:00:00.000Z
576
+
577
+ START_OF(DATE("2026-07-15T14:35:27"), "QUARTER");
578
+ // 2026-07-01T00:00:00.000Z
428
579
  ```
429
580
 
430
- Parameter:
581
+ Parameters:
582
+
583
+ | Parameter | Type | Required |
584
+ | --------- | --------------------- | -------- |
585
+ | `VALUE` | `date` or `dateRange` | Yes |
586
+ | `UNIT` | `string` | No |
431
587
 
432
- | Parameter | Type |
433
- | --------- | --------------------- |
434
- | `VALUE` | `date` or `dateRange` |
588
+ `UNIT` accepts `DAY`, `WEEK`, `MONTH`, `QUARTER` and `YEAR`.
589
+
590
+ Rules:
591
+
592
+ - Without `UNIT`, a `dateRange` yields its own `start` verbatim and a `date`
593
+ yields the start of its day.
594
+ - **Omitting `UNIT` is not the same as passing `"DAY"`.** On a `dateRange` the
595
+ bare call preserves the endpoint, while `"DAY"` imposes a day boundary:
596
+ `START_OF(DATE_RANGE(DATE("2026-07-08T15:30"), DATE("2026-07-25T09:00")))` is
597
+ `2026-07-08T15:30:00.000Z`, and the same call with `"DAY"` is
598
+ `2026-07-08T00:00:00.000Z`.
599
+ - With `UNIT`, a `dateRange` contributes its `start` and the boundary is
600
+ computed from there.
601
+ - The boundary is the one `CALENDAR_PERIOD` would produce for the same unit,
602
+ so it follows the timezone, calendar, `weekStartsOn` and
603
+ `fiscalYearStartMonth` of the resolution context. Under a timezone the snap
604
+ is to the local boundary: `START_OF(DATE("2026-07-01T02:00"), "MONTH")` at
605
+ `-05:00` lands in **June**, because `02:00Z` is 21:00 on 30 June locally.
435
606
 
436
607
  Output: `date`.
437
608
 
438
- Value type when used as root: `relative`.
609
+ Value type: delegated to `VALUE`, with or without `UNIT`. Temporality is derived
610
+ from `VALUE`.
439
611
 
440
- ### `END_OF(value)`
612
+ ### `END_OF(value, unit?)`
441
613
 
442
- Returns the end of a `date` or the `end` of a `dateRange`.
614
+ Returns the end of `VALUE`, or the end of the `UNIT` period containing it.
443
615
 
444
616
  ```ts
445
617
  END_OF(DATE("2026-07-08T15:30"));
@@ -447,17 +619,159 @@ END_OF(DATE("2026-07-08T15:30"));
447
619
 
448
620
  END_OF(CALENDAR_PERIOD("YEAR", -1));
449
621
  // 2025-12-31T23:59:59.999Z
622
+
623
+ END_OF(DATE("2026-07-15T14:35:27"), "QUARTER");
624
+ // 2026-09-30T23:59:59.999Z
625
+ ```
626
+
627
+ Parameters:
628
+
629
+ | Parameter | Type | Required |
630
+ | --------- | --------------------- | -------- |
631
+ | `VALUE` | `date` or `dateRange` | Yes |
632
+ | `UNIT` | `string` | No |
633
+
634
+ `UNIT` accepts `DAY`, `WEEK`, `MONTH`, `QUARTER` and `YEAR`.
635
+
636
+ The rules mirror `START_OF`, measured from the other edge: without `UNIT` a
637
+ `dateRange` yields its own `end` verbatim, and with `UNIT` the boundary is
638
+ computed from that `end`.
639
+
640
+ Output: `date`.
641
+
642
+ Value type: delegated to `VALUE`, with or without `UNIT`. Temporality is derived
643
+ from `VALUE`.
644
+
645
+ ### `SAME_PERIOD(reference, offset, unit, mode?)`
646
+
647
+ Shifts a range, keeping its calendar shape.
648
+
649
+ ```ts
650
+ SAME_PERIOD(PERIOD_AT("QUARTER", 1, 2026), -1, "YEAR");
651
+ // Q1 2025: 2025-01-01T00:00:00.000Z -> 2025-03-31T23:59:59.999Z
652
+
653
+ SAME_PERIOD(PERIOD_AT("MONTH", 2, 2026), 1, "MONTH");
654
+ // all of March, not the first 28 days of it
655
+ ```
656
+
657
+ Parameters:
658
+
659
+ | Parameter | Type | Required | Notes |
660
+ | ----------- | ---------------- | -------- | ----------------------------------------- |
661
+ | `REFERENCE` | `dateRange` | Yes | A range, not a single date |
662
+ | `OFFSET` | Integer `number` | Yes | Negative, zero, or positive |
663
+ | `UNIT` | `string` | Yes | `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` |
664
+ | `OVERFLOW_MODE` | `string` | No | `CLAMP` (default) or `OVERFLOW` |
665
+
666
+ Rules:
667
+
668
+ - The end is shifted as an **exclusive** boundary, one millisecond past the
669
+ inclusive end. That is what makes a whole period stay a whole period:
670
+ February plus one month is all of March, not its first 28 days.
671
+ - `UNIT` is the unit of the shift and is independent of the shape of
672
+ `REFERENCE`. A quarter can be shifted by a year.
673
+ - `OVERFLOW_MODE` behaves as it does on `OFFSET`.
674
+ - **Clamping can pull both shifted edges onto the same day.** 29 and 30
675
+ January both clamp onto 28 February. When the shifted end lands at or
676
+ before the shifted start, the result is the whole of the shifted start's
677
+ day, rather than a range whose end precedes its start or a range of zero
678
+ width. Under `OVERFLOW` the edges stay apart and no collapse happens.
679
+ - Clamping loses span, and the collapse only recovers the degenerate case. A
680
+ 26-hour reference whose edges both clamp into the same month comes back
681
+ shorter: `SAME_PERIOD(DATE_RANGE(DATE("2026-01-29T08:00"), DATE("2026-01-30T10:00")), 1, "MONTH")`
682
+ is two hours on 28 February. Use `OVERFLOW` when the span matters more than
683
+ the day-of-month.
684
+
685
+ Output: `dateRange`.
686
+
687
+ Value type: delegated to `REFERENCE`. Temporality is derived from `REFERENCE`.
688
+
689
+ ### `PRIOR_PERIOD(reference, unit?)`
690
+
691
+ The period immediately before `REFERENCE`, in one of two readings.
692
+
693
+ ```ts
694
+ // Reference date 2026-07-22.
695
+ PRIOR_PERIOD(RELATIVE_PERIOD(-44, "DAY"));
696
+ // the 45 days before the reference 45:
697
+ // 2026-04-24T00:00:00.000Z -> 2026-06-07T23:59:59.999Z
698
+
699
+ PRIOR_PERIOD(CALENDAR_PERIOD("MONTH", 0), "MONTH");
700
+ // the whole of June: 2026-06-01T00:00:00.000Z -> 2026-06-30T23:59:59.999Z
701
+ ```
702
+
703
+ Parameters:
704
+
705
+ | Parameter | Type | Required | Notes |
706
+ | --------------- | ----------- | -------- | ------------------------------- |
707
+ | `REFERENCE` | `dateRange` | Yes | A range, not a single date |
708
+ | `OVERFLOW_MODE` | `string` | No | `CLAMP` (default) or `OVERFLOW` |
709
+
710
+ Rules:
711
+
712
+ - **It shifts by the calendar unit the reference was built from**, read off
713
+ the expression rather than the resolved value:
714
+ `PRIOR_PERIOD(PERIOD_AT("MONTH", 2, 2026))` is January 2026 — the whole
715
+ month — not the 28 days before February.
716
+ - The unit is taken from `CALENDAR_PERIOD`'s `PERIOD` and `PERIOD_AT`'s
717
+ `PERIOD`. It cannot be taken from the value:
718
+ `DATE_RANGE(DATE("2026-07-01"), END_OF(DATE("2026-07-31")))` and
719
+ `CALENDAR_PERIOD("MONTH", 0)` are byte-identical in July 2026, and the two
720
+ deliberately answer differently.
721
+ - **A reference with no calendar unit behind it** — a `RELATIVE_PERIOD`, a
722
+ literal `DATE_RANGE`, anything composed — gets the immediately preceding
723
+ window of the same duration, abutting to the millisecond. That is the
724
+ epic's "mechanical comparison, independent of alignment":
725
+ `PRIOR_PERIOD(RELATIVE_PERIOD(-44, "DAY"))` is the 45 days before the
726
+ reference 45.
727
+ - The calendar reading follows the calendar, timezone, `weekStartsOn` and
728
+ `fiscalYearStartMonth` of the resolution context.
729
+ - `OVERFLOW_MODE` is accepted for symmetry with `OFFSET` and `SAME_PERIOD`
730
+ but currently reaches no arithmetic that can overflow — see
731
+ `docs/TECH-DEBT.md`.
732
+
733
+ Output: `dateRange`.
734
+
735
+ Value type: delegated to `REFERENCE`. Temporality is derived from `REFERENCE`.
736
+
737
+ ### `FIELD(column)`
738
+
739
+ Resolves a dataset column to the date it holds for the row being resolved.
740
+
741
+ ```ts
742
+ FIELD([shipping_date]);
743
+ // with columnValues: { shipping_date: "2026-03-14T09:20:00.000Z" }
744
+ // 2026-03-14T09:20:00.000Z
745
+
746
+ START_OF(FIELD([shipping_date]), "MONTH");
747
+ // 2026-03-01T00:00:00.000Z
450
748
  ```
451
749
 
452
750
  Parameter:
453
751
 
454
- | Parameter | Type |
455
- | --------- | --------------------- |
456
- | `VALUE` | `date` or `dateRange` |
752
+ | Parameter | Type | Required | Notes |
753
+ | --------- | -------- | -------- | --------------------------- |
754
+ | `COLUMN` | a column | Yes | `[column]`, not a string |
755
+
756
+ Rules:
757
+
758
+ - The argument **must be a column node**. `FIELD(TODAY())` and
759
+ `FIELD("2026-01-01")` are rejected. Using `[column]` rather than a string
760
+ keeps the dependency visible to the editor, which reads it off the parsed
761
+ expression.
762
+ - The value comes from `datePreset.columnValues`, keyed by column id. Three
763
+ distinct failures, each with its own error: the argument is not a column, no
764
+ value was supplied (`MISSING_COLUMN_VALUE`), or the value is not a date
765
+ (`INVALID_COLUMN_VALUE`).
766
+ - **A bare `[column]` is not a date preset.** It used to resolve to the
767
+ column's own name in the date slot, marked valid, and `START_OF([column])`
768
+ threw a `RangeError` out of the public API. `FIELD([column])` is the one
769
+ sanctioned spelling.
457
770
 
458
771
  Output: `date`.
459
772
 
460
- Value type when used as root: `relative`.
773
+ Value type: `dataBound`, always `dynamic`. It moves with the data rather than
774
+ with the clock, and nothing in the expression can make it static.
461
775
 
462
776
  ### `PART(date, part)`
463
777
 
@@ -465,7 +779,10 @@ Extracts one part of a date.
465
779
 
466
780
  ```ts
467
781
  PART(DATE("2026-07-08T15:30"), "DAY_OF_WEEK_NAME");
468
- // Wednesday
782
+ // 3 — the weekday ordinal, tagged as a weekday name
783
+
784
+ FORMAT(PART(DATE("2026-07-08T15:30"), "DAY_OF_WEEK_NAME"));
785
+ // "Wednesday"
469
786
  ```
470
787
 
471
788
  Parameters:
@@ -477,31 +794,230 @@ Parameters:
477
794
 
478
795
  Supported parts:
479
796
 
797
+ Every part is read from the date **as it falls in the resolution timezone**, so
798
+ a part never disagrees with the calendar day the rest of the engine resolved.
799
+ `PART(DATE("2026-08-01T02:00"), "MONTH")` is `8` in UTC and `7` under
800
+ `America/Bogota`, where that instant is 21:00 on 31 July.
801
+
480
802
  | Value | Output |
481
803
  | ------------------ | ---------------------------------------- |
482
- | `YEAR` | UTC year |
804
+ | `YEAR` | Year |
483
805
  | `QUARTER` | Quarter 1-4 |
484
806
  | `MONTH` | Month 1-12 |
485
- | `MONTH_NAME` | English month name |
807
+ | `MONTH_NAME` | Month 1-12, tagged as a month name |
486
808
  | `DAY` | Day of month |
487
809
  | `DAY_OF_MONTH` | Day of month |
488
- | `DAY_OF_WEEK` | UTC weekday, Sunday = 0 |
489
- | `DAY_OF_WEEK_NAME` | English weekday name |
810
+ | `DAY_OF_WEEK` | Weekday, Sunday = 0 |
811
+ | `DAY_OF_WEEK_NAME` | Weekday 0-6, tagged as a weekday name |
490
812
  | `DAY_OF_YEAR` | Day of year |
491
813
  | `WEEK_OF_YEAR` | Week according to the current convention |
492
- | `HOUR` | UTC hour |
493
- | `MINUTE` | UTC minute |
494
- | `SECOND` | UTC second |
814
+ | `HOUR` | Hour |
815
+ | `MINUTE` | Minute |
816
+ | `SECOND` | Second |
817
+
818
+ `WEEK_OF_YEAR` is the only part that also consults `weekStartsOn`; the weekday
819
+ ordinal is always Sunday-based.
820
+
821
+ **`PART` extracts and never localizes.** Every part returns a number, including
822
+ the two `_NAME` values: `MONTH_NAME` is the month number and `DAY_OF_WEEK_NAME`
823
+ the weekday ordinal. They exist so a result can record *what kind* of number it
824
+ is — rendering "September" or "Montag" is a presentation concern and belongs to
825
+ `FORMAT`, which is the one place locale is read.
826
+
827
+ Because a part is a number, it can fill a numeric argument and take part in
828
+ arithmetic: `PERIOD_AT("MONTH", 4, PART(TODAY(), "YEAR") - 1)` is April of last
829
+ year.
830
+
831
+ `PART` is a valid final result. It resolves to a **scalar**:
832
+
833
+ ```ts
834
+ type ScalarValue = {
835
+ value: number | string;
836
+ sourcePart: DatePresetSourcePart | null;
837
+ };
838
+ ```
839
+
840
+ `sourcePart` records which part the number came from, so a formatter can tell a
841
+ month from a weekday. `PART(TODAY(), "MONTH")` is
842
+ `{ value: 7, sourcePart: "MONTH" }`, and `PART(TODAY(), "MONTH_NAME")` is the
843
+ same number under a different tag — which is the only difference between the
844
+ two, and the whole reason both still exist.
845
+
846
+ **Arithmetic clears the tag.** `PART(TODAY(), "YEAR") - 1` is
847
+ `{ value: 2025, sourcePart: null }`: once a part has been computed with, there
848
+ is no honest way to say what it means any more. Nine is a month; eighteen is
849
+ not, and the engine cannot tell where it stopped being one.
850
+
851
+ Both facts are recorded on the node, by the parser, and not recomputed by
852
+ walking the tree. A node carries `dateScalar` when a date produced its value,
853
+ and `dateScalar.sourcePart` names the part it still is; arithmetic keeps the
854
+ first and nulls the second. That is why `1 + 2` is refused while
855
+ `PART(TODAY(), "YEAR") - 1` resolves — the primitive is `number` for both, and
856
+ provenance is the only thing that separates them. The field is declared on its
857
+ own interface, `DateScalarProvenance`, which `CommonAST` extends, and a function
858
+ opts in with `datePresetScalar` and `datePresetSourcePart` on its definition.
859
+
860
+ A number is a result only when a date produced it. `PART(TODAY(), "YEAR") - 1`
861
+ resolves; `1 + 2` does not, and neither does a bare `5`. The difference is
862
+ provenance, not type.
863
+
864
+ The response reports `primitive: "dateScalar"` for one. That field answers
865
+ *what shape is `expression`*, and a scalar is a record — reporting `number`
866
+ would describe something the caller never receives.
867
+
868
+ Inside the expression tree a part is still a number and a rendered value is
869
+ still a string, which is what decides that `PART(TODAY(), "MONTH") + 1` is
870
+ arithmetic while `"texto" + 1` is not, and what lets a part fill a numeric
871
+ argument. That distinction is internal; it does not reach the response.
872
+
873
+ A scalar's `value` may therefore be a number or a string.
874
+ `FunctionDefinition.primitiveResult` accepts a callable, so a function whose
875
+ result type depends on its arguments computes it per call rather than declaring
876
+ one type and returning another.
877
+
878
+ Use `isScalarValue` to narrow a `DatePresetValue`; `isDateRangeValue` is
879
+ exported alongside it.
880
+
881
+ ### `FORMAT(value, style?)`
882
+
883
+ Renders a resolved value for a viewer. It is the one function in the language
884
+ that reads `locale`, and the only way to turn a name tag into a word.
885
+
886
+ ```ts
887
+ FORMAT(TODAY());
888
+ // "Jul 22, 2026"
889
+
890
+ FORMAT(CALENDAR_PERIOD("MONTH", 0));
891
+ // "Jul 1, 2026 – Jul 31, 2026"
892
+
893
+ FORMAT(PART(TODAY(), "MONTH"), "full");
894
+ // "July" — and "Julio" or "Juli" for another viewer
895
+
896
+ FORMAT(PART(NOW(), "HOUR"), "00");
897
+ // "14"
898
+ ```
899
+
900
+ Parameters:
901
+
902
+ | Parameter | Type | Values |
903
+ | --------- | ---------------------------------------- | -------------------------- |
904
+ | `VALUE` | `date`, `dateRange`, `partialDate`, part | What there is to render |
905
+ | `STYLE` | `string`, optional | Depends on the value below |
906
+
907
+ **The style is written, never computed.** It has to be a string literal in the
908
+ formula. A computed style makes validity move with the clock: the inner render
909
+ of `FORMAT(TODAY(), FORMAT(PART(NOW(), "HOUR"), "00"))` spells a valid pattern
910
+ only while the hour is under ten, so the same formula would be accepted in the
911
+ morning and rejected in the afternoon.
912
+
913
+ **The style has to belong to the shape being rendered.** An unsupported
914
+ combination is `INVALID_ALLOW_VALUE`, never a silent fallback — a permissive
915
+ renderer answers `"0909"` to `FORMAT(TODAY(), "MMMM")`, which is the CLDR
916
+ spelling a user reaches for to get a month name.
917
+
918
+ For a `date` or a `dateRange`:
919
+
920
+ | Style | Renders |
921
+ | ------------------------------------------------- | -------------------------------- |
922
+ | omitted | Locale medium |
923
+ | `full`, `long`, `medium`, `short`, `short-padded` | The corresponding `dateStyle` |
924
+ | A token pattern | `yyyy-MM-dd`, `dd/MM/yyyy HH:mm` |
925
+
926
+ A token pattern is read run by run, and every run of a letter has to be a
927
+ supported token: `yyyy`, `yy`, `MM`, `M`, `dd`, `d`, `HH`, `H`, `mm`, `m`, `ss`,
928
+ `s`. So `MMMM` is a run of four `M`s and is rejected rather than read as two
929
+ month numbers, and `yyyyy` is not `yyyy` followed by `y`.
930
+
931
+ **A date carries a time only when it has one.** `FORMAT(TODAY())` is
932
+ `"Jul 22, 2026"` and `FORMAT(NOW())` is `"Jul 22, 2026, 2:35 PM"`; the value
933
+ decides, read in the timezone it will be rendered in, so `FORMAT(END_OF(TODAY()))`
934
+ still shows `11:59 PM`. A range takes the decision from its start, because an
935
+ endpoint that lands on 23:59:59.999 is a boundary and not a time anyone asked
936
+ to see. `FormatDatePreset` is unchanged: its default still includes the time.
937
+
938
+ For a `partialDate`, the vocabulary is `long` and `short`. A partial date has no
939
+ instant, so a token pattern has nothing to read.
940
+
941
+ For a **scalar**, the style depends on the part the number came from — this is
942
+ QRV-1069 §5.7, and it is why `PART` carries a tag at all:
943
+
944
+ | Scalar source | Pattern (`00`) | Name (`full`, `short`) | Omitted |
945
+ | ------------------------------------------------------------------------------------------ | -------------- | ---------------------- | --------- |
946
+ | Numeric part with a name mapping — `MONTH`, `DAY_OF_WEEK` | yes | yes | the digit |
947
+ | Numeric part with no name mapping — `YEAR`, `QUARTER`, `DAY`, `DAY_OF_MONTH`, `DAY_OF_YEAR`, `WEEK_OF_YEAR`, `HOUR`, `MINUTE`, `SECOND` | yes | rejected | the digit |
948
+ | Name part — `MONTH_NAME`, `DAY_OF_WEEK_NAME` | rejected | yes | the name |
949
+ | `TIMEZONE()` / `CONTEXT(property)` — neither exists yet | rejected | rejected | — |
950
+
951
+ A scalar pattern is a run of one to four zeros and means the width to pad to:
952
+ `FORMAT(PART(TODAY(), "MONTH"), "00")` is `"07"`.
953
+
954
+ **A scalar has to still carry its tag.** `FORMAT(PART(TODAY(), "YEAR") - 1)` is
955
+ rejected: arithmetic clears the tag, and a formatter must not guess it back. So
956
+ is `FORMAT(FORMAT(x))`, since a rendered value has nothing left to render.
495
957
 
496
- Important: `PART` is available as a QDP function, but it is not a valid final result for `TranspileDatePreset` because it returns `string`. It can be used as a helper function when accessing the QDP transpiler directly, but a final date preset expression must return `date`, `dateRange`, or `partialDate`.
958
+ A malformed `locale` in the resolution context is `UNRENDERABLE_VALUE` rather
959
+ than a `RangeError` out of the public API.
960
+
961
+ Output: `dateScalar`, always with `sourcePart: null`.
962
+
963
+ Value type: `scalar`. Temporality follows the value it rendered — `FORMAT(TODAY())`
964
+ is `dynamic`, `FORMAT(DATE("2026-07-08"))` is `static`. Presentation is not an
965
+ axis of the classification: the same formula classifies identically for every
966
+ viewer, and only its value differs.
967
+
968
+ `FORMAT` has no case in `TranspileJSONToDatePreset`, on the same reading that
969
+ gives `PART` and `FIELD` none: the picker constructs date preset nodes, and
970
+ these three are read out of one.
971
+
972
+ ## Calendar-part matching
973
+
974
+ A field can be matched against a `partialDate` pattern regardless of year — "all
975
+ orders shipped in September", "orders placed on a Monday". QRV-1069 defines that
976
+ as a **filter operator**, not an expression-language function, so there is no
977
+ `MATCHES(...)` to call. What this library ships is the engine behind it:
978
+
979
+ ```ts
980
+ DatePresetMatches(value: string | Date, pattern, context?): boolean
981
+ PartialDateToRanges(pattern: PartialDateValue, horizon: DateRangeValue, context?): DateRangeValue[]
982
+ ```
983
+
984
+ `DatePresetMatches` takes a `partialDate` or a `dateRange`. A range is an
985
+ instant comparison, inclusive at both ends. A partial date is a **conjunction**:
986
+ every stated part must match, an omitted part matches anything.
987
+
988
+ ```ts
989
+ DatePresetMatches("2026-09-14T00:00:00.000Z", { month: 9 }); // true
990
+ DatePresetMatches("2026-09-15T00:00:00.000Z", { month: 9, day: 14 }); // false
991
+ ```
992
+
993
+ Three things worth knowing, all consequences of matching through `PART`:
994
+
995
+ - It resolves in the context timezone. `2026-08-01T02:00Z` matches
996
+ `{ month: 7 }` under `America/Bogota`, where that instant is 31 July.
997
+ - The week follows `weekStartsOn` and is **not ISO-8601**. The same date is
998
+ week 30 with Sunday weeks and week 29 with Monday weeks.
999
+ - The quarter is **Gregorian**, even under a fiscal calendar. Extraction is
1000
+ Gregorian, and a fiscal quarter is a different question from a calendar one.
1001
+
1002
+ `PartialDateToRanges` expands a pattern into the concrete ranges it covers
1003
+ inside a horizon, so a future SQL emission can be
1004
+ `col BETWEEN … OR col BETWEEN …` rather than per-engine `EXTRACT`. The
1005
+ boundaries are computed once, by the same code that answers
1006
+ `DatePresetMatches`, which is what keeps the two in agreement: PostgreSQL's
1007
+ `EXTRACT(WEEK …)` is ISO-only, and `DAYOFWEEK` ships with five different bases
1008
+ across the engines. Its cost is linear in the days of the horizon.
497
1009
 
498
1010
  ## Differences Between Period Functions
499
1011
 
500
- | Function | Question it answers | Example | Type |
501
- | ----------------- | -------------------------------------------- | ----------------------------- | ------------------ |
502
- | `CALENDAR_PERIOD` | What is this/previous/next calendar period? | This month, last week | `relative` |
503
- | `PERIOD_AT` | What is period N inside a year? | April 2026, Q2 2026, W40 2026 | Current `relative` |
504
- | `RELATIVE_PERIOD` | What is the moving window from today/anchor? | Last 30 days | `rolling` |
1012
+ | Function | Question it answers | Example | Type |
1013
+ | ----------------- | -------------------------------------------- | ----------------------------- | --------- |
1014
+ | `CALENDAR_PERIOD` | What is this/previous/next calendar period? | This month, last week | `aligned` |
1015
+ | `TO_DATE` | How much of the current period has elapsed? | Month to date, year to date | `aligned` |
1016
+ | `PERIOD_AT` | What is period N inside a year? | April 2026, Q2 2026, W40 2026 | `aligned` |
1017
+ | `RELATIVE_PERIOD` | What is the moving window from today/anchor? | Last 30 days | `rolling` |
1018
+ | `OFFSET` | What is the single instant N units away? | 7 days ago, tomorrow | `rolling` |
1019
+ | `SAME_PERIOD` | What is this range, N units away? | The same quarter a year back | delegated |
1020
+ | `PRIOR_PERIOD` | What came immediately before this range? | The previous 45 days | delegated |
505
1021
 
506
1022
  Examples with current date `2026-07-22`:
507
1023
 
@@ -532,9 +1048,11 @@ It is not recommended to merge them into a single public function because they e
532
1048
  { type: "PARTIAL_DATE", value: { year?, quarter?, month?, week?, day? } }
533
1049
  { type: "RELATIVE_PERIOD", offset: -30, unit: "DAY", anchor?: DatePresetJSON }
534
1050
  { type: "CALENDAR_PERIOD", period: "MONTH", offset?: 0 }
1051
+ { type: "TO_DATE", unit: "MONTH" }
535
1052
  { type: "PERIOD_AT", period: "MONTH", position: 4 | "FIRST" | "LAST", year?: 2026 }
536
- { type: "START_OF", input: DatePresetJSON }
537
- { type: "END_OF", input: DatePresetJSON }
1053
+ { type: "OFFSET", point: DatePresetJSON, offset: -7, unit: "DAY", overflowMode?: "CLAMP" }
1054
+ { type: "START_OF", input: DatePresetJSON, unit?: "MONTH" }
1055
+ { type: "END_OF", input: DatePresetJSON, unit?: "MONTH" }
538
1056
  ```
539
1057
 
540
1058
  Example:
@@ -576,6 +1094,12 @@ Rules:
576
1094
 
577
1095
  ## Formatting
578
1096
 
1097
+ There are two doors into rendering, and they are not the same door.
1098
+ `FormatDatePreset` is a function a caller applies to a value it already has;
1099
+ `FORMAT` is a function the *formula* applies, so its result is the date preset
1100
+ and the style is persisted with the expression. Both read the same renderers in
1101
+ `src/utils/datePresetFormat.ts`; only `FORMAT` enforces §5.7.
1102
+
579
1103
  `FormatDatePreset` formats values using `Intl.DateTimeFormat`.
580
1104
 
581
1105
  By default, `date` and `dateRange` values include time using `timeStyle: "short"`. When `partialDateStyle: "short"` is used and no explicit `timeStyle` is provided, `date` and `dateRange` values omit time so they align with picker-style labels. Passing `timeStyle` explicitly keeps time in the output.
@@ -671,19 +1195,44 @@ FormatDatePreset(
671
1195
 
672
1196
  Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_PRESET_TOKEN_MAP`.
673
1197
 
1198
+ Five of these resolve to a **scalar** rather than a date: `CURRENT_DAY`,
1199
+ `CURRENT_DAY_OF_WEEK`, `CURRENT_DAY_OF_YEAR`, `CURRENT_MONTH_NAME` and
1200
+ `LAST_MONTH_NAME`. The two `_NAME` ones carry a number with a name tag, which
1201
+ `FORMAT` renders as a word. `CURRENT_TIMEZONE` is not here yet — it needs
1202
+ `TIMEZONE()`.
1203
+
1204
+ `LAST_MONTH_NAME` wraps its period in `START_OF`. Without it, `PART` receives a
1205
+ `dateRange` where it validates a `date`, and the expression does not resolve.
1206
+
1207
+ The token name is matched case-insensitively, and may be wrapped in `{{ }}`:
1208
+ `TODAY-7`, `today-7` and `{{ Today-7 }}` all resolve to the same expression. A
1209
+ name that is not a token returns `undefined` from `resolveDatePresetToken` and
1210
+ throws from `TranspileJSONToDatePreset`.
1211
+
674
1212
  | Token | QDP expression |
675
1213
  | ----------------------------- | --------------------------------------------- |
676
1214
  | `NOW` | `NOW()` |
677
1215
  | `TODAY` | `TODAY()` |
678
1216
  | `CURRENT_DATE` | `TODAY()` |
679
- | `TODAY-7` | `START_OF(RELATIVE_PERIOD(-7, "DAY"))` |
680
- | `TODAY-30` | `START_OF(RELATIVE_PERIOD(-30, "DAY"))` |
681
- | `TODAY-60` | `START_OF(RELATIVE_PERIOD(-60, "DAY"))` |
682
- | `TODAY-90` | `START_OF(RELATIVE_PERIOD(-90, "DAY"))` |
683
- | `TODAY-120` | `START_OF(RELATIVE_PERIOD(-120, "DAY"))` |
684
- | `TODAY-365` | `START_OF(RELATIVE_PERIOD(-365, "DAY"))` |
685
- | `YESTERDAY` | `START_OF(RELATIVE_PERIOD(-1, "DAY"))` |
686
- | `TOMORROW` | `START_OF(END_OF(RELATIVE_PERIOD(1, "DAY")))` |
1217
+ | `CURRENT_TIME` | `NOW()` |
1218
+ | `CURRENT_DAY` | `PART(TODAY(), "DAY_OF_WEEK_NAME")` |
1219
+ | `CURRENT_DAY_OF_WEEK` | `PART(TODAY(), "DAY_OF_WEEK")` |
1220
+ | `CURRENT_DAY_OF_YEAR` | `PART(TODAY(), "DAY_OF_YEAR")` |
1221
+ | `CURRENT_MONTH_NAME` | `PART(TODAY(), "MONTH_NAME")` |
1222
+ | `LAST_MONTH_NAME` | `PART(START_OF(CALENDAR_PERIOD("MONTH", -1)), "MONTH_NAME")` |
1223
+ | `TODAY-7` | `OFFSET(TODAY(), -7, "DAY")` |
1224
+ | `TODAY-30` | `OFFSET(TODAY(), -30, "DAY")` |
1225
+ | `TODAY-60` | `OFFSET(TODAY(), -60, "DAY")` |
1226
+ | `TODAY-90` | `OFFSET(TODAY(), -90, "DAY")` |
1227
+ | `TODAY-120` | `OFFSET(TODAY(), -120, "DAY")` |
1228
+ | `TODAY-365` | `OFFSET(TODAY(), -365, "DAY")` |
1229
+ | `YESTERDAY` | `OFFSET(TODAY(), -1, "DAY")` |
1230
+ | `LAST_7_DAYS` | `RELATIVE_PERIOD(-6, "DAY")` |
1231
+ | `LAST_30_DAYS` | `RELATIVE_PERIOD(-29, "DAY")` |
1232
+ | `TOMORROW` | `OFFSET(TODAY(), 1, "DAY")` |
1233
+ | `MTD` | `TO_DATE("MONTH")` |
1234
+ | `QTD` | `TO_DATE("QUARTER")` |
1235
+ | `YTD` | `TO_DATE("YEAR")` |
687
1236
  | `CURRENT_MONTH` | `CALENDAR_PERIOD("MONTH", 0)` |
688
1237
  | `CURRENT_MONTH_START` | `START_OF(CALENDAR_PERIOD("MONTH", 0))` |
689
1238
  | `CURRENT_MONTH_END` | `END_OF(CALENDAR_PERIOD("MONTH", 0))` |
@@ -704,6 +1253,7 @@ Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_P
704
1253
  | `LAST_QUARTER_END` | `END_OF(CALENDAR_PERIOD("QUARTER", -1))` |
705
1254
  | `CURRENT_YEAR` | `CALENDAR_PERIOD("YEAR", 0)` |
706
1255
  | `CURRENT_YEAR_START` | `START_OF(CALENDAR_PERIOD("YEAR", 0))` |
1256
+ | `CURRENT_YEAR_END` | `END_OF(CALENDAR_PERIOD("YEAR", 0))` |
707
1257
  | `LAST_YEAR` | `CALENDAR_PERIOD("YEAR", -1)` |
708
1258
  | `LAST_YEAR_START` | `START_OF(CALENDAR_PERIOD("YEAR", -1))` |
709
1259
  | `LAST_YEAR_END` | `END_OF(CALENDAR_PERIOD("YEAR", -1))` |
@@ -712,18 +1262,20 @@ Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_P
712
1262
 
713
1263
  Tokens can be wrapped like `{{TODAY-7}}`. The normalizer removes braces and outer spaces.
714
1264
 
715
- Scalar tokens such as `CURRENT_TIME`, `CURRENT_TIMEZONE`, and `CURRENT_DAY_OF_WEEK` are not supported as date presets because they do not return `date`, `dateRange`, or `partialDate`.
1265
+ `CURRENT_TIMEZONE` is not supported yet: it needs `TIMEZONE()`, which reads a property of the resolution context rather than extracting one from a date.
716
1266
 
717
1267
  ## Main Equivalences With The Date Picker
718
1268
 
719
1269
  | Picker expression | QDP expression | Note |
720
1270
  | --------------------------- | ------------------------------------------------------------ | -------------------------------------------------- |
721
1271
  | Today | `TODAY()` | Fixed date at the start of the current day |
722
- | Yesterday | `START_OF(RELATIVE_PERIOD(-1, "DAY"))` | Start of yesterday |
723
- | Tomorrow | `START_OF(END_OF(RELATIVE_PERIOD(1, "DAY")))` | Start of tomorrow |
1272
+ | Yesterday | `OFFSET(TODAY(), -1, "DAY")` | Start of yesterday |
1273
+ | Tomorrow | `OFFSET(TODAY(), 1, "DAY")` | Start of tomorrow |
724
1274
  | This month | `CALENDAR_PERIOD("MONTH", 0)` | Current calendar month |
725
1275
  | Previous month | `CALENDAR_PERIOD("MONTH", -1)` | Previous calendar month |
1276
+ | Last 7 days inclusive | `RELATIVE_PERIOD(-6, "DAY")` | Inclusive 7-day rolling window counting today |
726
1277
  | Last 30 days inclusive | `RELATIVE_PERIOD(-29, "DAY")` | Inclusive 30-day rolling window counting today |
1278
+ | Month to date | `TO_DATE("MONTH")` | Start of the month to the end of today |
727
1279
  | April | `PARTIAL_DATE("ANY", 4)` | Recurring month |
728
1280
  | April 2026 | `PERIOD_AT("MONTH", 4, 2026)` | Positional month in a year |
729
1281
  | Apr 15 | `PARTIAL_DATE("ANY", 4, "ANY", "ANY", 15)` | Recurring month/day |
@@ -764,29 +1316,38 @@ DATE_RANGE(DATE("2026-07-31T00:00"), DATE("2026-07-01T00:00"));
764
1316
  // INVALID_DATE_RANGE: end before start
765
1317
  ```
766
1318
 
767
- Additionally, any QDP expression whose final result is not `date`, `dateRange`, or `partialDate` returns `INVALID_DATE_PRESET_RESULT`.
1319
+ Additionally, any QDP expression whose final result is not `date`, `dateRange`,
1320
+ `partialDate`, or a scalar a date produced returns `INVALID_DATE_PRESET_RESULT`.
768
1321
 
769
1322
  ## Relevant Files
770
1323
 
771
1324
  | File | Responsibility |
772
1325
  | --------------------------------------------- | ----------------------------------------------------------------------- |
773
- | `src/date-presets/date-presets.ts` | Public API, JSON conversion, formatting, and `valueType` classification |
1326
+ | `src/date-presets/date-presets.ts` | Public API, JSON conversion, and the result guard |
1327
+ | `src/date-presets/date-preset-classification.ts` | The `valueType` / `temporality` slot map and classifier |
774
1328
  | `src/date-presets/date-preset-tokens.ts` | Legacy token map to QDP expressions |
775
1329
  | `src/functions/index.ts` | Function registration for `ENGINES.QDP` |
1330
+ | `src/utils/extractContextInfo.ts` | Splits the appended resolution context off a transpiler's arguments |
776
1331
  | `src/utils/datePresetUtils.ts` | Date, range, calendar, week, and partial date resolution |
1332
+ | `src/utils/datePresetFormat.ts` | Every renderer, and the only module that calls `Intl.DateTimeFormat` |
1333
+ | `src/parser/formula-parser.ts` | Stamps `isDateScalar` and `sourcePart` onto a call node |
1334
+ | `src/functions/format.ts` | `FORMAT`: the §5.7 validity table and the routing to a renderer |
777
1335
  | `src/utils/timezone.ts` | Timezone definition normalization and offset helpers |
778
1336
  | `src/functions/*.ts` | Individual QDP function definitions |
779
- | `__tests__/unit/datePresetTranspiler.test.ts` | Main transpilation and picker compatibility cases |
780
- | `__tests__/unit/datePresetJson.test.ts` | JSON to QDP conversion |
781
- | `__tests__/unit/datePresetFormat.test.ts` | Date and partial date formatting |
1337
+ | `__tests__/unit/datePreset*.test.ts` | Transpilation, classification, every function, tokens and JSON conversion |
1338
+ | `__tests__/unit/extractContextInfo.test.ts` | The context-info split and its registration guard |
1339
+ | `__tests__/unit/datePresetFormat.test.ts` | `FormatDatePreset`: date and partial date formatting |
1340
+ | `__tests__/unit/datePresetFormatFunction.test.ts` | `FORMAT` in the language, and the §5.7 table cell by cell |
782
1341
  | `__tests__/unit/datePresetTokens.test.ts` | Legacy tokens |
1342
+ | `__tests__/unit/datePresetCalendar.test.ts` | Fiscal, retail and timezone calendar resolution |
1343
+ | `scripts/generate-date-preset-examples.js` | Generator for the examples CSV |
783
1344
  | `date-preset-qdp-examples.csv` | Generated examples with tokens and picker expressions |
784
1345
 
785
1346
  ## Current State And Design Notes
786
1347
 
787
1348
  - QDP is limited to date preset functions in `ENGINE_FN_MAP[ENGINES.QDP]`.
788
- - Date preset expressions can be nested as long as the final result is `date`, `dateRange`, or `partialDate`.
1349
+ - Date preset expressions can be nested as long as the final result is `date`, `dateRange`, `partialDate`, or a scalar read from a date.
789
1350
  - `partialDate` was added as a primitive to represent incomplete date picker selections.
790
1351
  - Week compatibility with the date picker uses Sunday as the default start day and supports W54.
791
1352
  - `PERIOD_AT` supports symbolic positions `FIRST` and `LAST` to reduce ambiguity in variable-length periods.
792
- - For documentation and example auditing, the CSV `date-preset-qdp-examples.csv` contains resolved, formatted, and classified values.
1353
+ - For documentation and example auditing, the CSV `date-preset-qdp-examples.csv` contains resolved, formatted, and classified values. Regenerate it with `npm run examples:csv`; the reference date defaults to `2026-07-22`, matching this document. Note the unit tests pin a different instant (`2026-07-15T14:35:27.000Z`).