Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 8 additions & 18 deletions assets/src/legacy/timemanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* @license MPL-2.0
*/

import { timeManagerDatetimeFormat, timeManagerFilterResolution } from '../modules/utils/TimeManagerFilter.js';

var lizTimemanager = function() {

lizMap.events.on({
Expand Down Expand Up @@ -287,11 +289,11 @@ var lizTimemanager = function() {
var startField = layerConfig.startAttribute;
var endField = layerConfig.endAttribute;
var hasEndField = endField && endField != '' && endField != startField;
// Always use full ISO date format for the QGIS expression filter
// The attributeResolution controls the slider display, not the filter format
// Using year-only ('1928') or month-only ('2020-06') strings fails
// for DATE-typed fields in QGIS Server expression evaluation
var filterResolution = 'days';
// Keep the filter granularity aligned with the layer's configured
// attribute resolution so sub-day precision (minutes, hours,
// seconds) is preserved instead of being truncated to date-only,
// which returned no features for non-midnight timestamps (#7056).
var filterResolution = timeManagerFilterResolution(layerConfig['attributeResolution']);

if (hasEndField) {
// Interval overlap: feature is active during [min_val, max_val] when
Expand Down Expand Up @@ -535,19 +537,7 @@ var lizTimemanager = function() {
* @param timeResolution
*/
function formatDatetime(mytime, timeResolution){
var myDate = moment(mytime);
var dString = null;
switch(timeResolution){
case 'milliseconds': dString = 'YYYY-MM-DD HH:mm:ss';break;
case 'seconds': dString = 'YYYY-MM-DD HH:mm:ss';break;
case 'minutes': dString = 'YYYY-MM-DD HH:mm:00';break;
case 'hours': dString = 'YYYY-MM-DD HH:00';break;
case 'days': dString = 'YYYY-MM-DD';break;
case 'weeks': dString = 'YYYY-MM-DD';break;
case 'months': dString = 'YYYY-MM';break;
case 'years': dString = 'YYYY';break;
}
return myDate.format(dString);
return moment(mytime).format(timeManagerDatetimeFormat(timeResolution));
}

}
Expand Down
58 changes: 58 additions & 0 deletions assets/src/modules/utils/TimeManagerFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @module modules/utils/TimeManagerFilter.js
* @name TimeManagerFilter
* @copyright 2023 3Liz
* @license MPL-2.0
*/

/**
* Return the moment.js format pattern for a Time Manager resolution.
*
* The same patterns are used for the slider display and, once the resolution
* has been passed through {@link timeManagerFilterResolution}, for the layer
* filter expression sent to QGIS Server.
* @param {string} resolution - a Time Manager resolution: milliseconds,
* seconds, minutes, hours, days, weeks, months or years
* @returns {string} the moment.js format pattern
*/
export function timeManagerDatetimeFormat(resolution) {
switch (resolution) {
case 'milliseconds':
case 'seconds':
return 'YYYY-MM-DD HH:mm:ss';
case 'minutes':
return 'YYYY-MM-DD HH:mm:00';
case 'hours':
return 'YYYY-MM-DD HH:00';
case 'days':
case 'weeks':
return 'YYYY-MM-DD';
case 'months':
return 'YYYY-MM';
case 'years':
return 'YYYY';
default:
return 'YYYY-MM-DD';
}
}

/**
* Return the resolution to use when formatting the layer filter expression.
*
* The filter must keep the layer's configured resolution so that sub-day
* precision (minutes, hours, seconds) is preserved instead of being truncated
* to date-only, which returns no features for non-midnight timestamps (#7056).
*
* The `months` and `years` resolutions are clamped to `days`: they would
* otherwise emit month-only ('2020-06') or year-only ('1928') strings, which
* fail for DATE-typed fields in QGIS Server expression evaluation (#6571). The
* full ISO date ('YYYY-MM-DD') is valid for both DATE and timestamp fields.
* @param {string} attributeResolution - the layer configured attribute resolution
* @returns {string} the resolution to pass to {@link timeManagerDatetimeFormat}
*/
export function timeManagerFilterResolution(attributeResolution) {
if (attributeResolution === 'months' || attributeResolution === 'years') {
return 'days';
}
return attributeResolution || 'days';
}
64 changes: 64 additions & 0 deletions tests/js-units/node/utils/timeManagerFilter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect } from 'chai';

import { timeManagerDatetimeFormat, timeManagerFilterResolution } from 'assets/src/modules/utils/TimeManagerFilter.js';

describe('timeManagerFilterResolution', function () {
it('keeps sub-day resolutions so the filter is not truncated to midnight (#7056)', function () {
expect(timeManagerFilterResolution('milliseconds')).to.be.eq('milliseconds')
expect(timeManagerFilterResolution('seconds')).to.be.eq('seconds')
expect(timeManagerFilterResolution('minutes')).to.be.eq('minutes')
expect(timeManagerFilterResolution('hours')).to.be.eq('hours')
})

it('keeps day and week resolutions unchanged', function () {
expect(timeManagerFilterResolution('days')).to.be.eq('days')
expect(timeManagerFilterResolution('weeks')).to.be.eq('weeks')
})

it('clamps months and years to days to avoid partial-date strings failing on DATE fields (#6571)', function () {
expect(timeManagerFilterResolution('months')).to.be.eq('days')
expect(timeManagerFilterResolution('years')).to.be.eq('days')
})

it('falls back to days for empty or undefined resolution', function () {
expect(timeManagerFilterResolution(undefined)).to.be.eq('days')
expect(timeManagerFilterResolution('')).to.be.eq('days')
})
});

describe('timeManagerDatetimeFormat', function () {
it('includes the time of day for sub-day resolutions', function () {
expect(timeManagerDatetimeFormat('milliseconds')).to.be.eq('YYYY-MM-DD HH:mm:ss')
expect(timeManagerDatetimeFormat('seconds')).to.be.eq('YYYY-MM-DD HH:mm:ss')
expect(timeManagerDatetimeFormat('minutes')).to.be.eq('YYYY-MM-DD HH:mm:00')
expect(timeManagerDatetimeFormat('hours')).to.be.eq('YYYY-MM-DD HH:00')
})

it('uses a date-only pattern for days and weeks', function () {
expect(timeManagerDatetimeFormat('days')).to.be.eq('YYYY-MM-DD')
expect(timeManagerDatetimeFormat('weeks')).to.be.eq('YYYY-MM-DD')
})

it('uses partial-date patterns for months and years (slider display only)', function () {
expect(timeManagerDatetimeFormat('months')).to.be.eq('YYYY-MM')
expect(timeManagerDatetimeFormat('years')).to.be.eq('YYYY')
})
});

// The two helpers combined describe what the Time Manager layer filter emits:
// the resolution is clamped first, then formatted. This is the exact behaviour
// fixed for issue #7056.
describe('Time Manager filter datetime format', function () {
function filterFormat(attributeResolution) {
return timeManagerDatetimeFormat(timeManagerFilterResolution(attributeResolution))
}

it('preserves the time of day for a minutes layer (#7056)', function () {
expect(filterFormat('minutes')).to.be.eq('YYYY-MM-DD HH:mm:00')
})

it('never emits year-only or month-only strings for DATE fields (#6571)', function () {
expect(filterFormat('months')).to.be.eq('YYYY-MM-DD')
expect(filterFormat('years')).to.be.eq('YYYY-MM-DD')
})
});
Loading