feat(calculations): dateDiff primitive (closes scholiq deps #5)#1476
Conversation
Implements the `dateDiff` JSON-logic primitive in CalculationEvaluator
and CalculationAnnotationValidator per openregister#1470 §5.
- Supports 7 units: years, months, weeks, days, hours, minutes, seconds
- Dict-based syntax: { "dateDiff": { "from": "...", "to": "...", "unit": "..." } }
- "now" sentinel, @self.<field> references, and prop() refs are all accepted
- Calendar units (years/months) use DateInterval for leap-year accuracy
- Sub-day/week units use timestamp delta so DST transitions are consistent
- Returns null (not exception) when either operand is an unparseable date
- Validator checks required keys and rejects unknown unit literals at schema-save time
- 42 new unit tests covering positive/negative diffs for all 7 units,
leap years, end-of-month months, DST boundaries, null propagation,
@self refs, "now" sentinel, and validator integration
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 153/153 | |||
| npm | ✅ | ✅ 598/598 | |||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-05-11 19:33 UTC
Download the full PDF report from the workflow artifacts.
|
|
||
| return match ($unit) { | ||
| 'weeks' => (int) intdiv($deltaSecs, 604800), | ||
| 'days' => (int) intdiv($deltaSecs, 86400), |
There was a problem hiding this comment.
[CONCERN] DST spring-forward gives 0 days for a 23-hour local-timezone day
When inputs are date-only strings (e.g. '2026-03-29' / '2026-03-30') and the server is in a DST-observing timezone (e.g. Europe/Amsterdam), new DateTimeImmutable('2026-03-29') parses to midnight local time. The spring-forward day is only 82800 seconds long, so intdiv(82800, 86400) = 0 — the user gets 0 days instead of 1. Fix: normalise both operands to UTC before computing the timestamp delta for calendar-day units, or add a setTimezone(new DateTimeZone('UTC')) call inside toDateOrNull before the delta computation.
| $toRaw = $this->evaluate(object: $object, expression: $args['to']); | ||
|
|
||
| // Treat the special sentinel "now" (literal string) as current time. | ||
| if ($fromRaw === 'now') { |
There was a problem hiding this comment.
[CONCERN] "now" sentinel creates a server-local-timezone object; no UTC normalisation documented
new DateTimeImmutable('now') uses the PHP process's default timezone. If the other operand is a UTC string, the two objects have different timezone contexts. For months/years (DateInterval path) diff() is timezone-aware and the result can be off by 1 month on a timezone boundary. Document that callers must supply explicit UTC offsets, or normalise both operands to UTC before calling diff().
|
|
||
| // ----------------------------------------------------------------------- | ||
| // Annotation validator integration | ||
| // ----------------------------------------------------------------------- |
There was a problem hiding this comment.
[CONCERN] DST test uses UTC offsets — does not cover the real DST risk (server-tz date-only strings)
testDstSpringForwardDays uses +00:00 explicit-offset strings, which sidestep the DST issue entirely. The scenario that actually triggers the bug — dateDiff({from:'2026-03-29', to:'2026-03-30', unit:'days'}) on a Europe/Amsterdam server — is not tested. Add a test that passes date-only strings across the spring-forward boundary or document that date-only strings are UTC-assumed.
| return $sign * $interval->y; | ||
| } | ||
|
|
||
| return $sign * ($interval->y * 12 + $interval->m); |
There was a problem hiding this comment.
[CONCERN] months formula $interval->y * 12 + $interval->m truncates; Jan 31 → Feb 28 = 0 months
PHP DateInterval::diff('2026-01-31', '2026-02-28') yields y=0, m=0, d=28 — the user gets 0 months instead of 1. This is tested and documented in testEndOfMonthJanToFeb, but may surprise API consumers. Consider documenting this explicitly in the expression schema or accepting it as intentional PHP semantics.
|
|
||
| // Validate unit when it's a plain string literal (not a nested expression). | ||
| $unit = $args['unit']; | ||
| if (is_string($unit) === true && in_array($unit, self::VALID_DATE_DIFF_UNITS, true) === false) { |
There was a problem hiding this comment.
[CONCERN] Validator skips unit validation when unit is a dynamic expression
In walkDateDiff, if (is_string($unit) === true …) only validates literal string units — a nested expression like {'prop': 'unitField'} passes validation entirely unchecked. At runtime, CalculationEvaluator casts to string and validates, which throws. Consider adding a warning-level error or a note in the docblock.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Review
🟡 Concerns (5)
- DST spring-forward gives 0 days for a 23-hour local-timezone day —
lib/Service/Calculation/CalculationEvaluator.php:579 "now"sentinel creates a server-local-timezone object; no UTC normalisation documented —lib/Service/Calculation/CalculationEvaluator.php:548- DST test uses UTC offsets — does not cover the real DST risk (server-tz date-only strings) —
tests/Unit/Service/Calculation/DateDiffTest.php:657 - months formula
$interval->y * 12 + $interval->mtruncates; Jan 31 → Feb 28 = 0 months —lib/Service/Calculation/CalculationEvaluator.php:571 - Validator skips unit validation when
unitis a dynamic expression —lib/Service/Calculation/CalculationAnnotationValidator.php:320
🟢 Minor (3)
- Docblock claims epoch-delta is DST-safe for all sub-day units; this overstates the guarantee (
lib/Service/Calculation/CalculationEvaluator.php:582)
The docblock says 'elapsed-second delta so DST transitions are handled consistently'. This is only true when both inputs carry an explicit UTC offset. Date-only strings parsed in a local DST timezone produce a delta that differs from the wall-clock calendar day count. Revise to clarify that date-only strings are parsed in server local timezone. - testAtSelfFieldReference uses a non-standard
@selfobject shape (tests/Unit/Service/Calculation/DateDiffTest.php:696)
$object = ['@self' => ['dueDate' => '2026-06-01']]— verify this matches the real runtime shape thatCalculationEvaluator::propValueexpects for@self.*references; if the shape differs in production the test gives false confidence. - testNowSentinel will fail in year 2099 — use a relative assertion or freeze time (
tests/Unit/Service/Calculation/DateDiffTest.php:737)
$this->assertGreaterThan(0, $result)with a hardcoded'2099-12-31'target date will eventually become ≤ 0. Prefer injecting a clock stub or asserting relative to a controlled date.
Reviewed by WilcoLouwerse via automated batch review.
f86e83f
Summary
dateDiffJSON-logic primitive toCalculationEvaluatorper openregister#1470 §5CalculationAnnotationValidatorwithdateDiff-specific key and unit validationWhat was added
CalculationEvaluator::dateDiff(){ "dateDiff": { "from": "...", "to": "...", "unit": "..." } }years,months,weeks,days,hours,minutes,secondsfrom/toaccept: literal"now"(current server time), ISO-8601 strings,@self.<field>references, orprop()expressionsto>from)years/months) useDateIntervalfor leap-year / variable-month accuracynull(not an exception) when either operand cannot be parsed as a dateCalculationAnnotationValidatordateDiffadded toVALID_OPSwalkDateDiff()validates required keys (from,to,unit) and rejects unknown unit literals at schema-save timeTest coverage (42 new tests)
Quality gates
Review notes
DO NOT admin-merge — openregister is production-tier. Awaiting code review.