@qrvey/formula-lang 3.2.0-rc.1104 → 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 (223) hide show
  1. package/QRVEY-DATE-PRESETS.md +633 -70
  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/cjs/utils/timezone.js +7 -3
  106. package/dist/cjs/utils/timezone.js.map +1 -1
  107. package/dist/module/constants/index.d.ts +29 -1
  108. package/dist/module/constants/index.js +32 -0
  109. package/dist/module/constants/index.js.map +1 -1
  110. package/dist/module/constants/interfaces.d.ts +73 -4
  111. package/dist/module/date-presets/date-preset-classification.d.ts +41 -0
  112. package/dist/module/date-presets/date-preset-classification.js +179 -0
  113. package/dist/module/date-presets/date-preset-classification.js.map +1 -0
  114. package/dist/module/date-presets/date-preset-matches.d.ts +30 -0
  115. package/dist/module/date-presets/date-preset-matches.js +81 -0
  116. package/dist/module/date-presets/date-preset-matches.js.map +1 -0
  117. package/dist/module/date-presets/date-preset-tokens.d.ts +20 -8
  118. package/dist/module/date-presets/date-preset-tokens.js +36 -10
  119. package/dist/module/date-presets/date-preset-tokens.js.map +1 -1
  120. package/dist/module/date-presets/date-presets.d.ts +18 -1
  121. package/dist/module/date-presets/date-presets.js +95 -176
  122. package/dist/module/date-presets/date-presets.js.map +1 -1
  123. package/dist/module/date-presets/index.d.ts +3 -1
  124. package/dist/module/date-presets/index.js +3 -1
  125. package/dist/module/date-presets/index.js.map +1 -1
  126. package/dist/module/errors/dictionary.d.ts +6 -1
  127. package/dist/module/errors/dictionary.js +25 -0
  128. package/dist/module/errors/dictionary.js.map +1 -1
  129. package/dist/module/functions/calendarPeriod.js +7 -11
  130. package/dist/module/functions/calendarPeriod.js.map +1 -1
  131. package/dist/module/functions/dateRange.js +5 -2
  132. package/dist/module/functions/dateRange.js.map +1 -1
  133. package/dist/module/functions/endOf.js +17 -3
  134. package/dist/module/functions/endOf.js.map +1 -1
  135. package/dist/module/functions/field.d.ts +5 -0
  136. package/dist/module/functions/field.js +49 -0
  137. package/dist/module/functions/field.js.map +1 -0
  138. package/dist/module/functions/format.d.ts +2 -0
  139. package/dist/module/functions/format.js +278 -0
  140. package/dist/module/functions/format.js.map +1 -0
  141. package/dist/module/functions/index.d.ts +5 -0
  142. package/dist/module/functions/index.js +36 -1
  143. package/dist/module/functions/index.js.map +1 -1
  144. package/dist/module/functions/offset.d.ts +2 -0
  145. package/dist/module/functions/offset.js +54 -0
  146. package/dist/module/functions/offset.js.map +1 -0
  147. package/dist/module/functions/part.js +19 -3
  148. package/dist/module/functions/part.js.map +1 -1
  149. package/dist/module/functions/partialDate.js +53 -4
  150. package/dist/module/functions/partialDate.js.map +1 -1
  151. package/dist/module/functions/periodAt.js +5 -9
  152. package/dist/module/functions/periodAt.js.map +1 -1
  153. package/dist/module/functions/priorPeriod.d.ts +2 -0
  154. package/dist/module/functions/priorPeriod.js +43 -0
  155. package/dist/module/functions/priorPeriod.js.map +1 -0
  156. package/dist/module/functions/relativePeriod.js +7 -11
  157. package/dist/module/functions/relativePeriod.js.map +1 -1
  158. package/dist/module/functions/samePeriod.d.ts +2 -0
  159. package/dist/module/functions/samePeriod.js +54 -0
  160. package/dist/module/functions/samePeriod.js.map +1 -0
  161. package/dist/module/functions/startOf.js +17 -3
  162. package/dist/module/functions/startOf.js.map +1 -1
  163. package/dist/module/functions/toDate.d.ts +2 -0
  164. package/dist/module/functions/toDate.js +43 -0
  165. package/dist/module/functions/toDate.js.map +1 -0
  166. package/dist/module/functions/today.js +4 -2
  167. package/dist/module/functions/today.js.map +1 -1
  168. package/dist/module/grammar/generated/qformula.lang.js +8 -8
  169. package/dist/module/grammar/generated/qformula.lang.js.map +1 -1
  170. package/dist/module/grammar/generated/qformula.lang.terms.d.ts +1 -0
  171. package/dist/module/grammar/generated/qformula.lang.terms.js +1 -1
  172. package/dist/module/grammar/generated/qformula.lang.terms.js.map +1 -1
  173. package/dist/module/index.d.ts +2 -2
  174. package/dist/module/index.js +1 -1
  175. package/dist/module/index.js.map +1 -1
  176. package/dist/module/parser/formula-parser.js +18 -1
  177. package/dist/module/parser/formula-parser.js.map +1 -1
  178. package/dist/module/parser/json-parser.js +22 -23
  179. package/dist/module/parser/json-parser.js.map +1 -1
  180. package/dist/module/transpiler/columnTranspilation.js +15 -0
  181. package/dist/module/transpiler/columnTranspilation.js.map +1 -1
  182. package/dist/module/transpiler/index.js +5 -3
  183. package/dist/module/transpiler/index.js.map +1 -1
  184. package/dist/module/transpiler/qdpArithmetic.d.ts +15 -0
  185. package/dist/module/transpiler/qdpArithmetic.js +65 -0
  186. package/dist/module/transpiler/qdpArithmetic.js.map +1 -0
  187. package/dist/module/utils/alignedUnit.d.ts +14 -0
  188. package/dist/module/utils/alignedUnit.js +39 -0
  189. package/dist/module/utils/alignedUnit.js.map +1 -0
  190. package/dist/module/utils/datePresetFormat.d.ts +44 -0
  191. package/dist/module/utils/datePresetFormat.js +214 -0
  192. package/dist/module/utils/datePresetFormat.js.map +1 -0
  193. package/dist/module/utils/datePresetUtils.d.ts +63 -4
  194. package/dist/module/utils/datePresetUtils.js +188 -35
  195. package/dist/module/utils/datePresetUtils.js.map +1 -1
  196. package/dist/module/utils/escapeCharacters.js +2 -2
  197. package/dist/module/utils/escapeCharacters.js.map +1 -1
  198. package/dist/module/utils/extractContextInfo.d.ts +41 -0
  199. package/dist/module/utils/extractContextInfo.js +46 -0
  200. package/dist/module/utils/extractContextInfo.js.map +1 -0
  201. package/dist/module/utils/index.d.ts +1 -1
  202. package/dist/module/utils/index.js +1 -1
  203. package/dist/module/utils/index.js.map +1 -1
  204. package/dist/module/utils/isInteger.js +17 -4
  205. package/dist/module/utils/isInteger.js.map +1 -1
  206. package/dist/module/utils/removeQuotes.d.ts +8 -0
  207. package/dist/module/utils/removeQuotes.js +9 -0
  208. package/dist/module/utils/removeQuotes.js.map +1 -0
  209. package/dist/module/utils/timezone.js +7 -3
  210. package/dist/module/utils/timezone.js.map +1 -1
  211. package/docs/QRV-1069-analysis.md +458 -0
  212. package/docs/QRV-1069-jira-comment.md +35 -0
  213. package/docs/TECH-DEBT.md +351 -0
  214. package/package.json +5 -2
  215. package/scripts/date-preset-examples.data.js +269 -0
  216. package/scripts/generate-date-preset-examples.js +219 -0
  217. package/specs/QRV-1069--date-expression-framework/QRV-1069--additive-functions/input.md +51 -0
  218. package/specs/QRV-1069--date-expression-framework/QRV-1069--classification/input.md +51 -0
  219. package/specs/QRV-1069--date-expression-framework/QRV-1069--column-bound/input.md +89 -0
  220. package/specs/QRV-1069--date-expression-framework/QRV-1069--format/input.md +83 -0
  221. package/specs/QRV-1069--date-expression-framework/QRV-1069--scalar-results/input.md +55 -0
  222. package/specs/QRV-1069--date-expression-framework/README.md +53 -0
  223. 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
  ```
@@ -107,7 +147,7 @@ Current defaults:
107
147
 
108
148
  | Option | Default | Notes |
109
149
  | ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------ |
110
- | `timezone.offset` | `+00:00` | Supports `default`, `browser`, or custom offsets such as `UTC-5`, `-05:00`, `+5:30` |
150
+ | `timezone.offset` | `+00:00` | Supports `default`, `browser`, or custom offsets such as `UTC-5`, `-05:00`, `+5:30`. Valid range is `-12:00` to `+14:00`; offsets outside it fall back to `+00:00` |
111
151
  | `timezone.timeZone` | none | Optional IANA time zone identifier such as `America/Bogota`; when present it is used for calendar resolution |
112
152
  | `calendar` | `gregorian` | Also supports `corporate-fiscal`, `retail-4-4-5`, `retail-4-5-4` |
113
153
  | `locale` | `en-US` | Used by formatting options |
@@ -115,9 +155,12 @@ 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
 
162
+ Custom offsets accept the full UTC range `-12:00` to `+14:00`, including fractional offsets such as `+05:30` (India), `+05:45` (Nepal), or `-09:30` (Marquesas). Any minute value `00`-`59` is accepted; offsets outside the valid range fall back to `+00:00`.
163
+
121
164
  ### Expression timezone output
122
165
 
123
166
  Date preset resolution always calculates concrete instants internally. The `datePreset.applyTimezoneToExpression` option controls only the public `expression` format returned by `TranspileDatePreset`.
@@ -174,7 +217,7 @@ NOW();
174
217
 
175
218
  Output: `date`.
176
219
 
177
- Value type when used as the final result: `fixed`.
220
+ Value type when used as the final result: `explicit`, `dynamic`.
178
221
 
179
222
  ### `TODAY()`
180
223
 
@@ -187,7 +230,7 @@ TODAY();
187
230
 
188
231
  Output: `date`.
189
232
 
190
- Value type when used as the final result: `fixed`.
233
+ Value type when used as the final result: `explicit`, `dynamic`.
191
234
 
192
235
  ### `DATE(value)`
193
236
 
@@ -221,7 +264,7 @@ Rules:
221
264
 
222
265
  Output: `date`.
223
266
 
224
- Value type: `fixed`.
267
+ Value type: `explicit`, `static`.
225
268
 
226
269
  ### `DATE_RANGE(start, end)`
227
270
 
@@ -246,7 +289,7 @@ Rules:
246
289
 
247
290
  Output: `dateRange`.
248
291
 
249
- Value type: `fixed`.
292
+ Value type: `explicit`. Temporality is derived from `START` and `END`.
250
293
 
251
294
  ### `RELATIVE_PERIOD(offset, unit, anchor?)`
252
295
 
@@ -280,7 +323,61 @@ RELATIVE_PERIOD(-2, "WEEK", DATE("2026-07-08T15:30"));
280
323
 
281
324
  Output: `dateRange`.
282
325
 
283
- 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`.
284
381
 
285
382
  ### `CALENDAR_PERIOD(period, offset?)`
286
383
 
@@ -317,7 +414,47 @@ CALENDAR_PERIOD("QUARTER", -1);
317
414
 
318
415
  Output: `dateRange`.
319
416
 
320
- 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.
321
458
 
322
459
  ### `PERIOD_AT(period, position, year?)`
323
460
 
@@ -358,7 +495,8 @@ Week rules:
358
495
 
359
496
  Output: `dateRange`.
360
497
 
361
- 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.
362
500
 
363
501
  ### `PARTIAL_DATE(year?, month?, quarter?, week?, day?)`
364
502
 
@@ -407,15 +545,27 @@ PARTIAL_DATE("ANY", "ANY", "ANY", "ANY", 15);
407
545
  // Short format: Day 15
408
546
  ```
409
547
 
410
- 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.
411
560
 
412
- - `fixed` if it includes `year`, `month`, and `day`, without `week`.
413
- - `relative` if it includes `week`.
414
- - `recurring` for all other partial dates.
561
+ Value type, always with temporality `timeless`:
415
562
 
416
- ### `START_OF(value)`
563
+ - `explicit` if it includes `year`, `month`, and `day`, without `week`.
564
+ - `recurring` for every other partial date, `week` included.
417
565
 
418
- 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.
419
569
 
420
570
  ```ts
421
571
  START_OF(DATE("2026-07-08T15:30"));
@@ -423,21 +573,45 @@ START_OF(DATE("2026-07-08T15:30"));
423
573
 
424
574
  START_OF(CALENDAR_PERIOD("MONTH", -1));
425
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
426
579
  ```
427
580
 
428
- Parameter:
581
+ Parameters:
582
+
583
+ | Parameter | Type | Required |
584
+ | --------- | --------------------- | -------- |
585
+ | `VALUE` | `date` or `dateRange` | Yes |
586
+ | `UNIT` | `string` | No |
429
587
 
430
- | Parameter | Type |
431
- | --------- | --------------------- |
432
- | `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.
433
606
 
434
607
  Output: `date`.
435
608
 
436
- Value type when used as root: `relative`.
609
+ Value type: delegated to `VALUE`, with or without `UNIT`. Temporality is derived
610
+ from `VALUE`.
437
611
 
438
- ### `END_OF(value)`
612
+ ### `END_OF(value, unit?)`
439
613
 
440
- 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.
441
615
 
442
616
  ```ts
443
617
  END_OF(DATE("2026-07-08T15:30"));
@@ -445,17 +619,159 @@ END_OF(DATE("2026-07-08T15:30"));
445
619
 
446
620
  END_OF(CALENDAR_PERIOD("YEAR", -1));
447
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
448
748
  ```
449
749
 
450
750
  Parameter:
451
751
 
452
- | Parameter | Type |
453
- | --------- | --------------------- |
454
- | `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.
455
770
 
456
771
  Output: `date`.
457
772
 
458
- 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.
459
775
 
460
776
  ### `PART(date, part)`
461
777
 
@@ -463,7 +779,10 @@ Extracts one part of a date.
463
779
 
464
780
  ```ts
465
781
  PART(DATE("2026-07-08T15:30"), "DAY_OF_WEEK_NAME");
466
- // 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"
467
786
  ```
468
787
 
469
788
  Parameters:
@@ -475,31 +794,230 @@ Parameters:
475
794
 
476
795
  Supported parts:
477
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
+
478
802
  | Value | Output |
479
803
  | ------------------ | ---------------------------------------- |
480
- | `YEAR` | UTC year |
804
+ | `YEAR` | Year |
481
805
  | `QUARTER` | Quarter 1-4 |
482
806
  | `MONTH` | Month 1-12 |
483
- | `MONTH_NAME` | English month name |
807
+ | `MONTH_NAME` | Month 1-12, tagged as a month name |
484
808
  | `DAY` | Day of month |
485
809
  | `DAY_OF_MONTH` | Day of month |
486
- | `DAY_OF_WEEK` | UTC weekday, Sunday = 0 |
487
- | `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 |
488
812
  | `DAY_OF_YEAR` | Day of year |
489
813
  | `WEEK_OF_YEAR` | Week according to the current convention |
490
- | `HOUR` | UTC hour |
491
- | `MINUTE` | UTC minute |
492
- | `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.
493
957
 
494
- 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.
495
1009
 
496
1010
  ## Differences Between Period Functions
497
1011
 
498
- | Function | Question it answers | Example | Type |
499
- | ----------------- | -------------------------------------------- | ----------------------------- | ------------------ |
500
- | `CALENDAR_PERIOD` | What is this/previous/next calendar period? | This month, last week | `relative` |
501
- | `PERIOD_AT` | What is period N inside a year? | April 2026, Q2 2026, W40 2026 | Current `relative` |
502
- | `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 |
503
1021
 
504
1022
  Examples with current date `2026-07-22`:
505
1023
 
@@ -530,9 +1048,11 @@ It is not recommended to merge them into a single public function because they e
530
1048
  { type: "PARTIAL_DATE", value: { year?, quarter?, month?, week?, day? } }
531
1049
  { type: "RELATIVE_PERIOD", offset: -30, unit: "DAY", anchor?: DatePresetJSON }
532
1050
  { type: "CALENDAR_PERIOD", period: "MONTH", offset?: 0 }
1051
+ { type: "TO_DATE", unit: "MONTH" }
533
1052
  { type: "PERIOD_AT", period: "MONTH", position: 4 | "FIRST" | "LAST", year?: 2026 }
534
- { type: "START_OF", input: DatePresetJSON }
535
- { 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" }
536
1056
  ```
537
1057
 
538
1058
  Example:
@@ -574,6 +1094,12 @@ Rules:
574
1094
 
575
1095
  ## Formatting
576
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
+
577
1103
  `FormatDatePreset` formats values using `Intl.DateTimeFormat`.
578
1104
 
579
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.
@@ -669,19 +1195,44 @@ FormatDatePreset(
669
1195
 
670
1196
  Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_PRESET_TOKEN_MAP`.
671
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
+
672
1212
  | Token | QDP expression |
673
1213
  | ----------------------------- | --------------------------------------------- |
674
1214
  | `NOW` | `NOW()` |
675
1215
  | `TODAY` | `TODAY()` |
676
1216
  | `CURRENT_DATE` | `TODAY()` |
677
- | `TODAY-7` | `START_OF(RELATIVE_PERIOD(-7, "DAY"))` |
678
- | `TODAY-30` | `START_OF(RELATIVE_PERIOD(-30, "DAY"))` |
679
- | `TODAY-60` | `START_OF(RELATIVE_PERIOD(-60, "DAY"))` |
680
- | `TODAY-90` | `START_OF(RELATIVE_PERIOD(-90, "DAY"))` |
681
- | `TODAY-120` | `START_OF(RELATIVE_PERIOD(-120, "DAY"))` |
682
- | `TODAY-365` | `START_OF(RELATIVE_PERIOD(-365, "DAY"))` |
683
- | `YESTERDAY` | `START_OF(RELATIVE_PERIOD(-1, "DAY"))` |
684
- | `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")` |
685
1236
  | `CURRENT_MONTH` | `CALENDAR_PERIOD("MONTH", 0)` |
686
1237
  | `CURRENT_MONTH_START` | `START_OF(CALENDAR_PERIOD("MONTH", 0))` |
687
1238
  | `CURRENT_MONTH_END` | `END_OF(CALENDAR_PERIOD("MONTH", 0))` |
@@ -702,6 +1253,7 @@ Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_P
702
1253
  | `LAST_QUARTER_END` | `END_OF(CALENDAR_PERIOD("QUARTER", -1))` |
703
1254
  | `CURRENT_YEAR` | `CALENDAR_PERIOD("YEAR", 0)` |
704
1255
  | `CURRENT_YEAR_START` | `START_OF(CALENDAR_PERIOD("YEAR", 0))` |
1256
+ | `CURRENT_YEAR_END` | `END_OF(CALENDAR_PERIOD("YEAR", 0))` |
705
1257
  | `LAST_YEAR` | `CALENDAR_PERIOD("YEAR", -1)` |
706
1258
  | `LAST_YEAR_START` | `START_OF(CALENDAR_PERIOD("YEAR", -1))` |
707
1259
  | `LAST_YEAR_END` | `END_OF(CALENDAR_PERIOD("YEAR", -1))` |
@@ -710,18 +1262,20 @@ Legacy tokens are resolved with `TranspileJSONToDatePreset(token)` using `DATE_P
710
1262
 
711
1263
  Tokens can be wrapped like `{{TODAY-7}}`. The normalizer removes braces and outer spaces.
712
1264
 
713
- 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.
714
1266
 
715
1267
  ## Main Equivalences With The Date Picker
716
1268
 
717
1269
  | Picker expression | QDP expression | Note |
718
1270
  | --------------------------- | ------------------------------------------------------------ | -------------------------------------------------- |
719
1271
  | Today | `TODAY()` | Fixed date at the start of the current day |
720
- | Yesterday | `START_OF(RELATIVE_PERIOD(-1, "DAY"))` | Start of yesterday |
721
- | 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 |
722
1274
  | This month | `CALENDAR_PERIOD("MONTH", 0)` | Current calendar month |
723
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 |
724
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 |
725
1279
  | April | `PARTIAL_DATE("ANY", 4)` | Recurring month |
726
1280
  | April 2026 | `PERIOD_AT("MONTH", 4, 2026)` | Positional month in a year |
727
1281
  | Apr 15 | `PARTIAL_DATE("ANY", 4, "ANY", "ANY", 15)` | Recurring month/day |
@@ -762,29 +1316,38 @@ DATE_RANGE(DATE("2026-07-31T00:00"), DATE("2026-07-01T00:00"));
762
1316
  // INVALID_DATE_RANGE: end before start
763
1317
  ```
764
1318
 
765
- 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`.
766
1321
 
767
1322
  ## Relevant Files
768
1323
 
769
1324
  | File | Responsibility |
770
1325
  | --------------------------------------------- | ----------------------------------------------------------------------- |
771
- | `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 |
772
1328
  | `src/date-presets/date-preset-tokens.ts` | Legacy token map to QDP expressions |
773
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 |
774
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 |
775
1335
  | `src/utils/timezone.ts` | Timezone definition normalization and offset helpers |
776
1336
  | `src/functions/*.ts` | Individual QDP function definitions |
777
- | `__tests__/unit/datePresetTranspiler.test.ts` | Main transpilation and picker compatibility cases |
778
- | `__tests__/unit/datePresetJson.test.ts` | JSON to QDP conversion |
779
- | `__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 |
780
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 |
781
1344
  | `date-preset-qdp-examples.csv` | Generated examples with tokens and picker expressions |
782
1345
 
783
1346
  ## Current State And Design Notes
784
1347
 
785
1348
  - QDP is limited to date preset functions in `ENGINE_FN_MAP[ENGINES.QDP]`.
786
- - 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.
787
1350
  - `partialDate` was added as a primitive to represent incomplete date picker selections.
788
1351
  - Week compatibility with the date picker uses Sunday as the default start day and supports W54.
789
1352
  - `PERIOD_AT` supports symbolic positions `FIRST` and `LAST` to reduce ambiguity in variable-length periods.
790
- - 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`).