From ec7341934ddb13cf5401f0bbfd4199db33a79159 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:14:23 +0700 Subject: [PATCH 01/12] Time input component --- packages/common/src/constants/datetime.ts | 17 +++++++- .../form-elements/input/InputControl.vue | 4 ++ .../form-elements/input/InputTime.vue | 43 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/web-forms/src/components/form-elements/input/InputTime.vue diff --git a/packages/common/src/constants/datetime.ts b/packages/common/src/constants/datetime.ts index 79d231433..5be0fda8e 100644 --- a/packages/common/src/constants/datetime.ts +++ b/packages/common/src/constants/datetime.ts @@ -6,9 +6,15 @@ const ISO_DATE_LIKE_SUBPATTERN = '\\d{4}-\\d{2}-\\d{2}'; export const ISO_DATE_LIKE_PATTERN = new RegExp(`^${ISO_DATE_LIKE_SUBPATTERN}(?=T|$)`); -const ISO_TIME_LIKE_SUBPATTERN = `(${[ +const STRICT_TIME_FORMATS = [ '\\d{2}:\\d{2}:\\d{2}\\.\\d+', '\\d{2}:\\d{2}:\\d{2}', +]; + +const STRICT_ISO_TIME_SUBPATTERN = `(${[ ...STRICT_TIME_FORMATS ].join('|')})`; + +const ISO_TIME_LIKE_SUBPATTERN = `(${[ + ...STRICT_TIME_FORMATS, '\\d{2}:\\d{2}', '\\d{2}', ].join('|')})`; @@ -50,3 +56,12 @@ export const ISO_DATE_OR_DATE_TIME_LIKE_PATTERN = new RegExp( 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('') +); diff --git a/packages/web-forms/src/components/form-elements/input/InputControl.vue b/packages/web-forms/src/components/form-elements/input/InputControl.vue index 85d0b362c..1e11ae07e 100644 --- a/packages/web-forms/src/components/form-elements/input/InputControl.vue +++ b/packages/web-forms/src/components/form-elements/input/InputControl.vue @@ -10,6 +10,7 @@ import InputGeoMultiPoint from '@/components/form-elements/input/InputGeoMultiPo import InputInt from '@/components/form-elements/input/InputInt.vue'; import InputNumbersAppearance from '@/components/form-elements/input/InputNumbersAppearance.vue'; import InputText from '@/components/form-elements/input/InputText.vue'; +import InputTime from '@/components/form-elements/input/InputTime.vue'; import { IS_FORM_EDIT_MODE } from '@/lib/constants/injection-keys.ts'; import type { AnyInputNode } from '@getodk/xforms-engine'; import { inject } from 'vue'; @@ -49,6 +50,9 @@ const isFormEditMode = inject(IS_FORM_EDIT_MODE); + diff --git a/packages/web-forms/src/components/form-elements/input/InputTime.vue b/packages/web-forms/src/components/form-elements/input/InputTime.vue new file mode 100644 index 000000000..7772ddfc1 --- /dev/null +++ b/packages/web-forms/src/components/form-elements/input/InputTime.vue @@ -0,0 +1,43 @@ + + + + + From 43e7f67c45b13b347d04b7030c13ec0f817dfed5 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:04:32 +0700 Subject: [PATCH 02/12] Time codec --- packages/common/src/constants/datetime.ts | 22 ++--- .../xforms-engine/src/client/InputNode.ts | 4 + packages/xforms-engine/src/client/NoteNode.ts | 4 + .../src/lib/codecs/TimeValueCodec.ts | 84 +++++++++++++++++++ .../src/lib/codecs/getSharedValueCodec.ts | 7 +- 5 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts diff --git a/packages/common/src/constants/datetime.ts b/packages/common/src/constants/datetime.ts index 5be0fda8e..e639ced00 100644 --- a/packages/common/src/constants/datetime.ts +++ b/packages/common/src/constants/datetime.ts @@ -6,18 +6,13 @@ const ISO_DATE_LIKE_SUBPATTERN = '\\d{4}-\\d{2}-\\d{2}'; export const ISO_DATE_LIKE_PATTERN = new RegExp(`^${ISO_DATE_LIKE_SUBPATTERN}(?=T|$)`); -const STRICT_TIME_FORMATS = [ - '\\d{2}:\\d{2}:\\d{2}\\.\\d+', - '\\d{2}:\\d{2}:\\d{2}', -]; +const STRICT_TIME_FORMATS = ['\\d{2}:\\d{2}:\\d{2}\\.\\d+', '\\d{2}:\\d{2}:\\d{2}']; -const STRICT_ISO_TIME_SUBPATTERN = `(${[ ...STRICT_TIME_FORMATS ].join('|')})`; +const STRICT_ISO_TIME_SUBPATTERN = `(${[...STRICT_TIME_FORMATS].join('|')})`; -const ISO_TIME_LIKE_SUBPATTERN = `(${[ - ...STRICT_TIME_FORMATS, - '\\d{2}:\\d{2}', - '\\d{2}', -].join('|')})`; +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}$`); @@ -58,10 +53,5 @@ export const ISO_DATE_OR_DATE_TIME_NO_OFFSET_PATTERN = new RegExp( ); export const ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN = new RegExp( - [ - '^', - STRICT_ISO_TIME_SUBPATTERN, - `(${ISO_OFFSET_SUBPATTERN})?`, - '$', - ].join('') + ['^', STRICT_ISO_TIME_SUBPATTERN, `(${ISO_OFFSET_SUBPATTERN})?`, '$'].join('') ); diff --git a/packages/xforms-engine/src/client/InputNode.ts b/packages/xforms-engine/src/client/InputNode.ts index f665d948c..1205402d8 100644 --- a/packages/xforms-engine/src/client/InputNode.ts +++ b/packages/xforms-engine/src/client/InputNode.ts @@ -85,6 +85,7 @@ export type StringInputValue = InputValue<'string'>; export type IntInputValue = InputValue<'int'>; export type DecimalInputValue = InputValue<'decimal'>; export type DateInputValue = InputValue<'date'>; +export type TimeInputValue = InputValue<'time'>; export type GeopointInputValue = InputValue<'geopoint'>; export type GeoshapeInputValue = InputValue<'geoshape'>; export type GeotraceInputValue = InputValue<'geotrace'>; @@ -93,6 +94,7 @@ export type StringInputNode = InputNode<'string'>; export type IntInputNode = InputNode<'int'>; export type DecimalInputNode = InputNode<'decimal'>; export type DateInputNode = InputNode<'date'>; +export type TimeInputNode = InputNode<'time'>; export type GeopointInputNode = InputNode<'geopoint'>; export type GeoshapeInputNode = InputNode<'geoshape'>; export type GeotraceInputNode = InputNode<'geotrace'>; @@ -104,6 +106,7 @@ type SupportedInputValueType = | 'int' | 'decimal' | 'date' + | 'time' | 'geopoint' | 'geoshape' | 'geotrace'; @@ -119,6 +122,7 @@ export type AnyInputNode = | IntInputNode | DecimalInputNode | DateInputNode + | TimeInputNode | GeopointInputNode | GeoshapeInputNode | GeotraceInputNode diff --git a/packages/xforms-engine/src/client/NoteNode.ts b/packages/xforms-engine/src/client/NoteNode.ts index 0a2ee41b0..147e1b931 100644 --- a/packages/xforms-engine/src/client/NoteNode.ts +++ b/packages/xforms-engine/src/client/NoteNode.ts @@ -82,6 +82,7 @@ export type StringNoteValue = NoteValue<'string'>; export type IntNoteValue = NoteValue<'int'>; export type DecimalNoteValue = NoteValue<'decimal'>; export type DateNoteValue = NoteValue<'date'>; +export type TimeNoteValue = NoteValue<'time'>; export type GeopointNoteValue = NoteValue<'geopoint'>; export type GeoshapeNoteValue = NoteValue<'geoshape'>; export type GeotraceNoteValue = NoteValue<'geotrace'>; @@ -90,6 +91,7 @@ export type StringNoteNode = NoteNode<'string'>; export type IntNoteNode = NoteNode<'int'>; export type DecimalNoteNode = NoteNode<'decimal'>; export type DateNoteNode = NoteNode<'date'>; +export type TimeNoteNode = NoteNode<'time'>; export type GeopointNoteNode = NoteNode<'geopoint'>; export type GeoshapeNoteNode = NoteNode<'geoshape'>; export type GeotraceNoteNode = NoteNode<'geotrace'>; @@ -101,6 +103,7 @@ type SupportedNoteValueType = | 'int' | 'decimal' | 'date' + | 'time' | 'geopoint' | 'geoshape' | 'geotrace'; @@ -116,6 +119,7 @@ export type AnyNoteNode = | IntNoteNode | DecimalNoteNode | DateNoteNode + | TimeNoteNode | GeopointNoteNode | GeoshapeNoteNode | GeotraceNoteNode diff --git a/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts b/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts new file mode 100644 index 000000000..17987b82a --- /dev/null +++ b/packages/xforms-engine/src/lib/codecs/TimeValueCodec.ts @@ -0,0 +1,84 @@ +import { ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN } from '@getodk/common/constants/datetime.ts'; +import { Temporal } from 'temporal-polyfill'; +import { type CodecDecoder, type CodecEncoder, ValueCodec } from './ValueCodec.ts'; + +export type TimeRuntimeValue = string | null; + +export type TimeInputValue = + | Date + | Temporal.PlainDateTime + | Temporal.PlainTime + | Temporal.ZonedDateTime + | string + | null; + +const validateTimeString = (value: string): TimeRuntimeValue => { + if (value == null || !ISO_TIME_WITH_OPTIONAL_OFFSET_PATTERN.test(value)) { + return null; + } + + return value; +}; + +const parseZonedDateTimeToString = (value: Temporal.ZonedDateTime): string => { + if (!value) { + return ''; + } + const time = value.toPlainTime().toString(); + const offset = value.offset; + return `${time}${offset === '+00:00' ? 'Z' : offset}`; +}; + +/** + * Converts a time-like value ({@link TimeInputValue}) to a strict time string. + * Honors timezones/offsets if present on the input objects. + * + * @param value - The value to convert. + * @returns A time string or empty string if invalid. + */ +const toTimeString = (value: TimeInputValue): string => { + if (value == null) { + return ''; + } + + try { + if (value instanceof Date) { + const zonedTime = Temporal.Instant.fromEpochMilliseconds(value.getTime()).toZonedDateTimeISO( + Temporal.Now.timeZoneId() + ); + return parseZonedDateTimeToString(zonedTime); + } + + if (value instanceof Temporal.PlainTime) { + return value.toString(); + } + + if (value instanceof Temporal.PlainDateTime) { + return value.toPlainTime().toString(); + } + + if (value instanceof Temporal.ZonedDateTime) { + return parseZonedDateTimeToString(value); + } + + return validateTimeString(value) ?? ''; + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Error parsing time value:', error); + return ''; + } +}; + +export class TimeValueCodec extends ValueCodec<'time', TimeRuntimeValue, TimeInputValue> { + constructor() { + const encodeValue: CodecEncoder = (value) => { + return toTimeString(value); + }; + + const decodeValue: CodecDecoder = (value: string) => { + return validateTimeString(value); + }; + + super('time', encodeValue, decodeValue); + } +} diff --git a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts index 02fb74321..e62019677 100644 --- a/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts +++ b/packages/xforms-engine/src/lib/codecs/getSharedValueCodec.ts @@ -24,6 +24,7 @@ import { } from './geolocation/Geotrace.ts'; import { type IntInputValue, type IntRuntimeValue, IntValueCodec } from './IntValueCodec.ts'; import { StringValueCodec } from './StringValueCodec.ts'; +import { type TimeInputValue, type TimeRuntimeValue, TimeValueCodec } from './TimeValueCodec.ts'; import type { ValueCodec } from './ValueCodec.ts'; import { ValueTypePlaceholderCodec } from './ValueTypePlaceholderCodec.ts'; @@ -33,7 +34,7 @@ interface RuntimeValuesByType { readonly decimal: DecimalRuntimeValue; readonly boolean: string; readonly date: DatetimeRuntimeValue; - readonly time: string; + readonly time: TimeRuntimeValue; readonly dateTime: string; readonly geopoint: GeopointRuntimeValue; readonly geotrace: GeotraceRuntimeValue; @@ -51,7 +52,7 @@ interface RuntimeInputValuesByType { readonly decimal: DecimalInputValue; readonly boolean: string; readonly date: DatetimeInputValue; - readonly time: string; + readonly time: TimeInputValue; readonly dateTime: string; readonly geopoint: GeopointInputValue; readonly geotrace: GeotraceInputValue; @@ -80,7 +81,7 @@ export const sharedValueCodecs: SharedValueCodecs = { decimal: new DecimalValueCodec(), boolean: new ValueTypePlaceholderCodec('boolean'), date: new DateValueCodec(), - time: new ValueTypePlaceholderCodec('time'), + time: new TimeValueCodec(), dateTime: new ValueTypePlaceholderCodec('dateTime'), binary: new ValueTypePlaceholderCodec('binary'), barcode: new ValueTypePlaceholderCodec('barcode'), From 13d27834f1893eb97cf169cd7bf2f953179fc951 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:15:36 +0700 Subject: [PATCH 03/12] test forms --- .../date.xml => date-and-time/date-and-time.xml} | 11 +++++++++++ .../src/fixtures/notes/2-all-possible-notes.xml | 5 +++++ 2 files changed, 16 insertions(+) rename packages/common/src/fixtures/{date/date.xml => date-and-time/date-and-time.xml} (87%) diff --git a/packages/common/src/fixtures/date/date.xml b/packages/common/src/fixtures/date-and-time/date-and-time.xml similarity index 87% rename from packages/common/src/fixtures/date/date.xml rename to packages/common/src/fixtures/date-and-time/date-and-time.xml index f1d443dfc..944a952dd 100644 --- a/packages/common/src/fixtures/date/date.xml +++ b/packages/common/src/fixtures/date-and-time/date-and-time.xml @@ -20,6 +20,9 @@ When was the last time you ate vegetables? + + What time is your first meal? + @@ -34,6 +37,9 @@ Quand est-ce que vous avez mangé des légumes pour la dernière fois ? + + À quelle heure prends-tu ton premier repas ? + @@ -43,6 +49,7 @@ + @@ -55,6 +62,7 @@ relevant=" /data/dates/date_of_birth != """/> + @@ -72,6 +80,9 @@