Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/nice-spiders-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@getodk/xforms-engine': minor
'@getodk/web-forms': minor
'@getodk/scenario': patch
'@getodk/common': patch
'@getodk/xpath': patch
---

Add support to datetime question type.
29 changes: 17 additions & 12 deletions packages/common/src/constants/datetime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const DAY_MILLISECONDS = 1000 * 60 * 60 * 24;

export const FRACTIONAL_SECOND_DIGITS = 3;

export const MILLISECOND_NANOSECONDS = BigInt(1_000_000);

const ISO_DATE_LIKE_SUBPATTERN = '\\d{4}-\\d{2}-\\d{2}';
Expand All @@ -8,16 +10,17 @@ export const ISO_DATE_LIKE_PATTERN = new RegExp(`^${ISO_DATE_LIKE_SUBPATTERN}(?=

const STRICT_TIME_FORMATS = ['\\d{2}:\\d{2}:\\d{2}\\.\\d+', '\\d{2}:\\d{2}:\\d{2}'];

// Strict: requires HH:MM:SS (with optional fractional seconds).
const STRICT_ISO_TIME_SUBPATTERN = `(${[...STRICT_TIME_FORMATS].join('|')})`;

// "Like" (lenient): also accepts partial times (e.g., HH:MM or HH alone).
const ISO_TIME_LIKE_SUBPATTERN = `(${[...STRICT_TIME_FORMATS, '\\d{2}:\\d{2}', '\\d{2}'].join(
'|'
)})`;

export const ISO_TIME_LIKE_PATTERN = new RegExp(`^${ISO_TIME_LIKE_SUBPATTERN}$`);

const TIMEZONE_OFFSET_SUBPATTERN = '[-+]\\d{2}:\\d{2}';

// Detects presence of a timezone offset at the end of a string. It doesn't validate its range.
export const TIMEZONE_OFFSET_PATTERN = new RegExp(`${TIMEZONE_OFFSET_SUBPATTERN}$`);

const ISO_OFFSET_SUBPATTERN = `(${TIMEZONE_OFFSET_SUBPATTERN}|Z)`;
Expand All @@ -28,30 +31,32 @@ const ISO_OFFSET_SUBPATTERN = `(${TIMEZONE_OFFSET_SUBPATTERN}|Z)`;
*/
export const VALID_OFFSET_VALUE = new RegExp('^([+-]([0][0-9]|1[0-4]):([0-5][0-9])|Z)$', 'i');

export const ISO_DATE_TIME_LIKE_PATTERN = new RegExp(
// Used by XPath coercion. It accepts offset-bearing strings (e.g. 2026-04-22T14:30:00+07:00).
export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
[
'^',
ISO_DATE_LIKE_SUBPATTERN,
'T',
ISO_TIME_LIKE_SUBPATTERN,
`(${ISO_OFFSET_SUBPATTERN})`,
`(T${ISO_TIME_LIKE_SUBPATTERN}(${ISO_OFFSET_SUBPATTERN})?)?`,
'$',
].join('')
);

export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp(
// Used by DateValueCodec. It intentionally rejects strings with timezone offsets.
export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
);

export const ISO_DATE_TIME_WITH_OPTIONAL_OFFSET_PATTERN = new RegExp(
[
'^',
ISO_DATE_LIKE_SUBPATTERN,
`(T${ISO_TIME_LIKE_SUBPATTERN}(${ISO_OFFSET_SUBPATTERN})?)?`,
'T',
STRICT_ISO_TIME_SUBPATTERN,
`(${ISO_OFFSET_SUBPATTERN})?`,
'$',
].join('')
);

export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp(
['^', ISO_DATE_LIKE_SUBPATTERN, `(T${ISO_TIME_LIKE_SUBPATTERN})?`, '$'].join('')
);

export const ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN = new RegExp(
['^', STRICT_ISO_TIME_SUBPATTERN, `(${ISO_OFFSET_SUBPATTERN})?`, '$'].join('')
);
16 changes: 15 additions & 1 deletion packages/common/src/fixtures/date-and-time/date-and-time.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<text id="/data/dates/time:label">
<value>What time is your first meal?</value>
</text>
<text id="/data/dates/last_full_meal:label">
<value>When did you last have a full meal?</value>
</text>
</translation>
<translation lang="French (fr)">
<text id="/data/dates/survey_date:label">
Expand All @@ -40,6 +43,9 @@
<text id="/data/dates/time:label">
<value>À quelle heure prends-tu ton premier repas ?</value>
</text>
<text id="/data/dates/last_full_meal:label">
<value>Quand avez-vous mangé un repas complet pour la dernière fois ?</value>
</text>
</translation>
<translation lang="Spanish (es)" default="true()">
<text id="/data/dates/survey_date:label">
Expand All @@ -57,6 +63,9 @@
<text id="/data/dates/time:label">
<value>¿A que hora es su primera comida?</value>
</text>
<text id="/data/dates/last_full_meal:label">
<value>¿Cuándo fue la última vez que comió una comida completa?</value>
</text>
</translation>
</itext>
<instance>
Expand All @@ -67,6 +76,7 @@
<fruits_date/>
<vegetables_date/>
<time>22:02:10.562+07:00</time>
<last_full_meal/>
</dates>
<meta>
<instanceID/>
Expand All @@ -80,6 +90,7 @@
<bind nodeset="/data/dates/vegetables_date" type="date" required="false()"
relevant=" /data/dates/date_of_birth != &quot;&quot;" readonly="/data/dates/date_of_birth &lt;= today()"/>
<bind nodeset="/data/dates/time" type="time" required="true()" />
<bind nodeset="/data/dates/last_full_meal" type="dateTime" required="false()"/>
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
</model>
</h:head>
Expand All @@ -100,6 +111,9 @@
<input ref="/data/dates/time">
<label ref="jr:itext('/data/dates/time:label')"/>
</input>
<input ref="/data/dates/last_full_meal">
<label ref="jr:itext('/data/dates/last_full_meal:label')"/>
</input>
</group>
</h:body>
</h:html>
</h:html>
5 changes: 5 additions & 0 deletions packages/common/src/fixtures/notes/2-all-possible-notes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<note_calc_decimal_from_int />
<date_note>2025-12-21T23:30:05</date_note>
<time_note>22:02:10.562+06:00</time_note>
<datetime_note>2026-04-22T14:30:00+07:00</datetime_note>
<geopoint_note>38.253094215699576 21.756382658677467 0 150</geopoint_note>
<geotrace_note>37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0</geotrace_note>
<geoshape_note>37.7749 -122.4194 0 0; 37.775 -122.419 0 0; 37.774 -122.420 0 0; 37.7749 -122.4194 0 0</geoshape_note>
Expand All @@ -44,6 +45,7 @@
calculate="/data/group/read_only_int_value + 1.5" readonly="true()" />
<bind nodeset="/data/group/date_note" type="date" readonly="true()" />
<bind nodeset="/data/group/time_note" type="time" readonly="true()" />
<bind nodeset="/data/group/datetime_note" type="datetime" readonly="true()" />
<bind nodeset="/data/group/geopoint_note" type="geopoint" readonly="true()" />
<bind nodeset="/data/group/geotrace_note" type="geotrace" readonly="true()" />
<bind nodeset="/data/group/geoshape_note" type="geoshape" readonly="true()" />
Expand Down Expand Up @@ -90,6 +92,9 @@
<input ref="/data/group/time_note">
<label>A note with time type</label>
</input>
<input ref="/data/group/datetime_note">
<label>A note with datetime type</label>
</input>
<input ref="/data/group/geopoint_note">
<label>A note with geopoint type</label>
</input>
Expand Down
137 changes: 137 additions & 0 deletions packages/scenario/test/datetime-bind-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
bind,
body,
head,
html,
input,
mainInstance,
model,
t,
title,
} from '@getodk/common/test-utils/xform-dsl/index.ts';
import { assert, beforeEach, describe, expect, it } from 'vitest';
import { InputNodeAnswer } from '../src/answer/InputNodeAnswer.ts';
import { ModelValueNodeAnswer } from '../src/answer/ModelValueNodeAnswer.ts';
import { Scenario } from '../src/jr/Scenario.ts';

describe('Datetime bind type', () => {
const formTitle = 'Datetime bind types';
const relevancePath = '/root/relevance-trigger';
const relevanceExpression = `${relevancePath} = 'yes'`;

const formDefinition = html(
head(
title(formTitle),
model(
mainInstance(
t(
'root id="datetime-types"',
t('relevance-trigger', 'yes'),
t('model-only-datetime', '2026-04-22T14:30:00.123Z'),
t('input-datetime', '2026-01-01T00:00:00Z')
)
),
bind('/root/model-only-datetime').type('dateTime').relevant(relevanceExpression),
bind('/root/input-datetime').type('dateTime').relevant(relevanceExpression)
)
),
body(input(relevancePath), input('/root/input-datetime'))
);

let scenario: Scenario;
beforeEach(async () => {
scenario = await Scenario.init(formTitle, formDefinition);
});

describe('model-only values', () => {
const getModelAnswer = () => {
const answer = scenario.answerOf('/root/model-only-datetime');
assert(answer instanceof ModelValueNodeAnswer);
assert(answer.valueType === 'dateTime');
return answer as ModelValueNodeAnswer<'dateTime'>;
};

it('has correct static type', () => {
const answer = getModelAnswer();
expect(answer.value).toBeTypeOf('string');
});

it('has a datetime populated value', () => {
const answer = getModelAnswer();
expect(answer.value).to.equal('2026-04-22T14:30:00.123Z');
});

it('has null as a blank value when not relevant', () => {
scenario.answer(relevancePath, 'no');
const answer = getModelAnswer();
expect(answer.value).toBeNull();
});
});

describe('inputs', () => {
const getInputAnswer = () => {
const answer = scenario.answerOf('/root/input-datetime');
assert(answer instanceof InputNodeAnswer);
assert(answer.valueType === 'dateTime');
return answer as InputNodeAnswer<'dateTime'>;
};

it('has correct static type', () => {
const answer = getInputAnswer();
expect(answer.value).toBeTypeOf('string');
});

it('has a datetime populated value', () => {
const answer = getInputAnswer();
expect(answer.value).to.equal('2026-01-01T00:00:00Z');
expect(answer.stringValue).toEqual('2026-01-01T00:00:00Z');
});

it('has null as a blank value when not relevant', () => {
scenario.answer(relevancePath, 'no');
const answer = getInputAnswer();
expect(answer.value).toBeNull();
expect(answer.stringValue).toBe('');
});

it.each([
['standard UTC time', '2026-04-22T14:30:00Z'],
['positive timezone offset', '2026-04-22T14:30:00+07:00'],
['negative timezone offset', '2026-04-22T14:30:00-05:00'],
['fractional seconds', '2026-04-22T14:30:00.123Z'],
['local time without timezone', '2026-04-22T14:30:00'],
['leap year', '2024-02-29T12:00:00Z'],
['end of year and day', '2026-12-31T23:59:59Z'],
['beginning of year and day', '2026-01-01T00:00:00Z'],
])('sets value with valid datetime: %s (%s)', (_description, expression) => {
scenario.answer('/root/input-datetime', expression);
const answer = getInputAnswer();

expect(answer.value).to.deep.equal(expression);
expect(answer.stringValue).toEqual(expression);
});

it.each([
['missing T separator', '2026-04-22 14:30:00Z'],
['missing time component', '2026-04-22'],
['missing date component', '14:30:00Z'],
['wrong date order (MM-DD-YYYY)', '04-22-2026T14:30:00Z'],
['two-digit year', '26-04-22T14:30:00Z'],
['invalid month (13)', '2026-13-22T14:30:00Z'],
['invalid day for month (April 31)', '2026-04-31T14:30:00Z'],
['invalid leap year (Feb 29 on non-leap year)', '2026-02-29T14:30:00Z'],
['invalid hour (25)', '2026-04-22T25:30:00Z'],
['invalid minute (60)', '2026-04-22T14:60:00Z'],
['invalid second (61)', '2026-04-22T14:30:61Z'],
['invalid timezone format (missing colon)', '2026-04-22T14:30:00+0700'],
['lack of zero-padding', '2026-4-2T14:3:0Z'],
['free text string', 'not a datetime'],
])('has null when invalid datetime is passed: %s (%s)', (_description, expression) => {
scenario.answer('/root/input-datetime', expression);
const answer = getInputAnswer();

expect(answer.value).toBeNull();
expect(answer.stringValue).toBe('');
});
});
});
17 changes: 17 additions & 0 deletions packages/web-forms/src/assets/styles/primevue.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
.p-button:disabled {
cursor: default;
}

.p-datepicker-panel {
.p-datepicker-title button {
font-size: var(--odk-hint-font-size);
}
}

.p-datepicker {
.p-datepicker-input-icon-container {
top: 19px;

svg {
width: 20px;
height: 20px;
}
}
}
}

// Modals and dropdowns are outside the .odk-form class scope.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ControlText from '@/components/form-elements/ControlText.vue';
import InputGeopoint from '@/components/form-elements/input/geopoint/InputGeopoint.vue';
import InputGeopointWithMap from '@/components/form-elements/input/InputGeopointWithMap.vue';
import InputDate from '@/components/form-elements/input/InputDate.vue';
import InputDateTime from '@/components/form-elements/input/InputDateTime.vue';
import InputDecimal from '@/components/form-elements/input/InputDecimal.vue';
import InputGeoMultiPoint from '@/components/form-elements/input/InputGeoMultiPoint.vue';
import InputInt from '@/components/form-elements/input/InputInt.vue';
Expand Down Expand Up @@ -53,6 +54,9 @@ const isFormEditMode = inject(IS_FORM_EDIT_MODE);
<template v-else-if="node.valueType === 'time'">
<InputTime :question="node" />
</template>
<template v-else-if="node.valueType === 'dateTime'">
<InputDateTime :question="node" />
</template>
<template v-else>
<InputText :node="node" />
</template>
Expand Down
Loading