fix(embed): resolve transform values as local dates and give ranges whole days
Review findings: - A bare SAS number was turned into a UTC instant and then read with local accessors, so west of UTC the value landed a day early and the anchor was computed from the wrong quarter; the round trip to a SAS day number was one out. Built with local calendar construction instead, matching parseDate and convertJsDateToSasDate. - RANGE passed keepTime to both bounds, so a DATETIME range started at the value's clock time on day one and ended at it on the last day, dropping the rest of both boundary days. A range now covers whole days: period start at midnight, period end at 23:59:59. - A RANGE fed more than one parameter value produced no clause and no warning, reading as a filter that matched nothing. One BETWEEN cannot express several periods, so the count mismatch is now reported.
This commit is contained in:
1 parent
cd1ce4a7e2
commit
10157c1159
4 files changed
+93
-5
No files matched your search
@@ -421,6 +421,51 @@ describe('VaFilterService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips a bare SAS day number unchanged', () => {
|
||||
// VA may send the SAS internal value rather than a formatted string. The
|
||||
// Date a transform works from is local, so the number must come back out of
|
||||
// the clause identical - a UTC-built Date shifts a day west of UTC.
|
||||
const map = new Map([
|
||||
[
|
||||
'selected',
|
||||
{
|
||||
column: 'SOME_DATE',
|
||||
transform: {
|
||||
op: 'ADD' as const,
|
||||
anchor: 'QUARTER_START' as const,
|
||||
offset: -1
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
const [clause] = build([{ label: 'SELECTED', value: 24380 }], map)
|
||||
expect(clause.RAW_VALUE).toBe('(24380,24288)')
|
||||
})
|
||||
|
||||
it('reports a range mapping fed more than one value', () => {
|
||||
const map = new Map([
|
||||
[
|
||||
'selected',
|
||||
{
|
||||
column: 'SOME_DATE',
|
||||
transform: {
|
||||
op: 'RANGE' as const,
|
||||
period: 'QUARTER' as const,
|
||||
offset: -1
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
const problems = service.unsupportedTransforms(
|
||||
msg([{ label: 'SELECTED', value: ['01APR2026', '01JUL2026'] }]),
|
||||
map,
|
||||
[{ NAME: 'SOME_DATE', DDTYPE: 'DATE', FMTNAME: 'DATE9' }]
|
||||
)
|
||||
expect(problems).toHaveSize(1)
|
||||
expect(problems[0]).toContain('is a range')
|
||||
expect(problems[0]).toContain('2 values')
|
||||
})
|
||||
|
||||
it('applies a transform on the deferred initial load too', () => {
|
||||
const map = new Map([
|
||||
[
|
||||
|
||||
@@ -179,6 +179,11 @@ export class VaFilterService {
|
||||
* A VA value as a local Date, for a transform to work from. Formatted strings
|
||||
* are parsed by shape; a bare number is already the SAS internal value, so it
|
||||
* is converted back from days (date) or seconds (datetime) since 1960-01-01.
|
||||
*
|
||||
* Built with local calendar construction, matching `parseDate` and
|
||||
* `HelperService.convertJsDateToSasDate`: a UTC instant read back through local
|
||||
* accessors lands on the previous day west of UTC, and the round trip to a SAS
|
||||
* day number would be one out.
|
||||
*/
|
||||
private valueAsDate(
|
||||
value: any,
|
||||
@@ -189,9 +194,12 @@ export class VaFilterService {
|
||||
if (s === '') return null
|
||||
if (this.isBareNumber(s)) {
|
||||
const internal = Number(s)
|
||||
const ms =
|
||||
kind === 'datetime' ? internal * 1000 : internal * 24 * 60 * 60 * 1000
|
||||
return new Date(Date.UTC(1960, 0, 1) + ms)
|
||||
if (kind !== 'datetime') return new Date(1960, 0, 1 + internal)
|
||||
// Seconds since midnight 1960-01-01, split into whole days and a remainder
|
||||
// so the constructor normalises the clock time locally.
|
||||
const days = Math.floor(internal / 86400)
|
||||
const seconds = internal - days * 86400
|
||||
return new Date(1960, 0, 1 + days, 0, 0, seconds)
|
||||
}
|
||||
return kind === 'datetime'
|
||||
? this.parseDatetime(s, order)
|
||||
@@ -318,6 +326,15 @@ export class VaFilterService {
|
||||
problems.add(
|
||||
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but its value was not a date - the filter was ignored.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
// One BETWEEN can only express one period. A multi-select would have to
|
||||
// become an OR of ranges, which the clause model does not carry, so say so
|
||||
// rather than leaving the parameter silently dropped.
|
||||
if (isRangeTransform(target.transform) && values.length > 1) {
|
||||
problems.add(
|
||||
`VA parameter "${paramName}" maps to ${target.column}:${spec}, which is a range, but the parameter has ${values.length} values - a range needs one value, so the filter was ignored.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return Array.from(problems)
|
||||
|
||||
@@ -155,6 +155,27 @@ describe('va_parmap transforms', () => {
|
||||
})
|
||||
|
||||
describe('time of day', () => {
|
||||
it('gives a datetime range whole days, not the value clock time', () => {
|
||||
// A BETWEEN that starts at 13:45 on day one loses the earlier part of that
|
||||
// day, and one that ends at 13:45 on the last day loses the rest of it.
|
||||
const value = new Date(2026, 7, 15, 13, 45, 30)
|
||||
const [start, end] = applyVaTransform(
|
||||
value,
|
||||
{ op: 'RANGE', period: 'QUARTER', offset: 0 },
|
||||
true
|
||||
)
|
||||
expect([
|
||||
start.getHours(),
|
||||
start.getMinutes(),
|
||||
start.getSeconds()
|
||||
]).toEqual([0, 0, 0])
|
||||
expect([end.getHours(), end.getMinutes(), end.getSeconds()]).toEqual([
|
||||
23, 59, 59
|
||||
])
|
||||
expect(ymd(start)).toBe('2026-07-01')
|
||||
expect(ymd(end)).toBe('2026-09-30')
|
||||
})
|
||||
|
||||
it('keeps the time for datetime columns and drops it otherwise', () => {
|
||||
const value = new Date(2026, 9, 1, 13, 45, 30)
|
||||
const add: VaTransform = {
|
||||
|
||||
@@ -122,18 +122,23 @@ export function applyVaTransform(
|
||||
keepTime = false
|
||||
): Date[] {
|
||||
if (transform.op === 'RANGE') {
|
||||
// A range covers whole days: the bounds are the period's own, never the
|
||||
// value's clock time. Reusing keepTime here would start the range mid-morning
|
||||
// on day one and end it mid-morning on the last day, dropping the rest of
|
||||
// both boundary days from a BETWEEN.
|
||||
const start = resolveAnchor(
|
||||
(transform.period + '_START') as VaAnchor,
|
||||
value,
|
||||
transform.offset,
|
||||
keepTime
|
||||
false
|
||||
)
|
||||
const end = resolveAnchor(
|
||||
(transform.period + '_END') as VaAnchor,
|
||||
value,
|
||||
transform.offset,
|
||||
keepTime
|
||||
false
|
||||
)
|
||||
if (keepTime) end.setHours(23, 59, 59, 0)
|
||||
return [start, end]
|
||||
}
|
||||
const anchor = resolveAnchor(
|
||||
|
||||
Reference in new issue
Block a user