diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.html b/src/app/shared/components/datepicker-input/datepicker-input.component.html
new file mode 100644
index 000000000..01c69a8d7
--- /dev/null
+++ b/src/app/shared/components/datepicker-input/datepicker-input.component.html
@@ -0,0 +1,58 @@
+
+
+@if (showDatepicker()) {
+
+
+
+}
diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.scss b/src/app/shared/components/datepicker-input/datepicker-input.component.scss
new file mode 100644
index 000000000..cb93355ef
--- /dev/null
+++ b/src/app/shared/components/datepicker-input/datepicker-input.component.scss
@@ -0,0 +1,123 @@
+@import 'colors';
+@import 'mixins';
+
+:host {
+ position: relative;
+ display: block;
+ height: 40px;
+}
+
+.pr-date-input-group {
+ @include input-container;
+ padding: 0 0 0 12px;
+
+ &:focus-within,
+ &.active {
+ @include input-focus-state;
+ }
+}
+
+.pr-date-segment {
+ @include input-segment;
+ width: 36px;
+
+ &.year {
+ width: 48px;
+ }
+}
+
+.pr-separator {
+ color: $PR-blue-400;
+ font-size: 14px;
+ margin: 0 2px;
+}
+
+.pr-calendar-icon-wrapper {
+ @include icon-wrapper;
+ margin-left: auto;
+}
+
+.pr-icon-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ color: $PR-blue-600;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ color: $PR-blue;
+ }
+
+ &.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .material-icons {
+ font-size: 20px;
+ }
+}
+
+.pr-datepicker-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ margin-top: 4px;
+ width: fit-content;
+ background: $white;
+ border: 1px solid $PR-blue-100;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba($black, 0.12);
+ padding: 8px;
+
+ ::ng-deep ngb-datepicker {
+ border: none;
+ background: transparent;
+
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .ngb-dp-header {
+ padding-bottom: 4px;
+ }
+
+ .ngb-dp-weekday {
+ color: $PR-blue-600;
+ font-size: 12px;
+ }
+
+ .ngb-dp-day {
+ width: 2.2rem;
+ height: 2.2rem;
+
+ .btn-light {
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+ font-size: 13px;
+
+ &:hover {
+ background: $PR-blue-25;
+ }
+
+ &.bg-primary {
+ background: $PR-blue !important;
+ color: $white;
+ border-radius: 6px;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts
new file mode 100644
index 000000000..509dbcb67
--- /dev/null
+++ b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts
@@ -0,0 +1,221 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { DateModel } from '@shared/services/edtf-service/edtf.service';
+import { DatepickerInputComponent } from './datepicker-input.component';
+
+@Component({
+ standalone: true,
+ imports: [DatepickerInputComponent],
+ template: ``,
+})
+class TestHostComponent {
+ date: DateModel = { year: '', month: '', day: '' };
+ disabled = false;
+ lastEmittedDate: DateModel | null = null;
+
+ onDateChange(newDate: DateModel): void {
+ this.lastEmittedDate = newDate;
+ this.date = newDate;
+ }
+}
+
+describe('DatepickerInputComponent', () => {
+ let hostComponent: TestHostComponent;
+ let fixture: ComponentFixture;
+ let component: DatepickerInputComponent;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ hostComponent = fixture.componentInstance;
+ fixture.detectChanges();
+ component = fixture.debugElement.children[0].componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ const mockEvent = (value: string): Event =>
+ ({ target: { value } }) as unknown as Event;
+
+ it('should accept valid 4-digit year and emit', () => {
+ component.updateYear(mockEvent('2026'));
+
+ expect(hostComponent.lastEmittedDate?.year).toBe('2026');
+ });
+
+ it('should emit incomplete year as raw digits (no X in input)', () => {
+ component.updateYear(mockEvent('202'));
+
+ expect(hostComponent.lastEmittedDate?.year).toBe('202');
+ });
+
+ it('should reject non-numeric year', () => {
+ component.updateYear(mockEvent('20ab'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should accept year padded with leading zeros (ISO 8601)', () => {
+ component.updateYear(mockEvent('0985'));
+
+ expect(hostComponent.lastEmittedDate?.year).toBe('0985');
+ });
+
+ it('should accept valid 2-digit month and emit', () => {
+ component.updateMonth(mockEvent('06'));
+
+ expect(hostComponent.lastEmittedDate?.month).toBe('06');
+ });
+
+ it('should emit single digit month', () => {
+ component.updateMonth(mockEvent('1'));
+
+ expect(hostComponent.lastEmittedDate?.month).toBe('1');
+ });
+
+ it('should not emit or auto-focus for invalid month', () => {
+ component.updateMonth(mockEvent('13'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should reject non-numeric month', () => {
+ component.updateMonth(mockEvent('ab'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should accept valid 2-digit day for month and emit', () => {
+ hostComponent.date = { year: '2026', month: '01', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('31'));
+
+ expect(hostComponent.lastEmittedDate?.day).toBe('31');
+ });
+
+ it('should emit single digit day', () => {
+ hostComponent.date = { year: '2026', month: '01', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('3'));
+
+ expect(hostComponent.lastEmittedDate?.day).toBe('3');
+ });
+
+ it('should not emit day greater than max for month', () => {
+ hostComponent.date = { year: '2026', month: '02', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('30'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should accept day 29 for February in leap year', () => {
+ hostComponent.date = { year: '2024', month: '02', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('29'));
+
+ expect(hostComponent.lastEmittedDate?.day).toBe('29');
+ });
+
+ it('should not emit day 29 for February in non-leap year', () => {
+ hostComponent.date = { year: '2025', month: '02', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('29'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should not emit day 31 for 30-day months', () => {
+ hostComponent.date = { year: '2026', month: '04', day: '' };
+ fixture.detectChanges();
+ component.updateDay(mockEvent('31'));
+
+ expect(hostComponent.lastEmittedDate).toBeNull();
+ });
+
+ it('should allow typing first digit 0 or 1 for month', () => {
+ const input = { value: '0' } as HTMLInputElement;
+ component.updateMonth({ target: input } as unknown as Event);
+
+ expect(input.value).toBe('0');
+ });
+
+ it('should reject first digit > 1 for month', () => {
+ hostComponent.date = { year: '', month: '', day: '' };
+ fixture.detectChanges();
+ const input = { value: '5' } as HTMLInputElement;
+ component.updateMonth({ target: input } as unknown as Event);
+
+ expect(input.value).toBe('');
+ });
+
+ it('should emit when clearing year field', () => {
+ hostComponent.date = { year: '2026', month: '02', day: '18' };
+ fixture.detectChanges();
+ component.updateYear(mockEvent(''));
+
+ expect(hostComponent.lastEmittedDate).toBeTruthy();
+ expect(hostComponent.lastEmittedDate.year).toBe('');
+ });
+
+ it('should emit intermediate digits as the user types the year', () => {
+ component.updateYear(mockEvent('19'));
+
+ expect(hostComponent.lastEmittedDate?.year).toBe('19');
+ });
+
+ it('should toggle datepicker', () => {
+ component.toggleDatepicker();
+
+ expect(component.showDatepicker()).toBeTrue();
+ component.toggleDatepicker();
+
+ expect(component.showDatepicker()).toBeFalse();
+ });
+
+ it('should not toggle datepicker when disabled', () => {
+ hostComponent.disabled = true;
+ fixture.detectChanges();
+ component.toggleDatepicker();
+
+ expect(component.showDatepicker()).toBeFalse();
+ });
+
+ it('should emit date and close datepicker on date select', () => {
+ component.showDatepicker.set(true);
+ component.onDateSelect({ year: 2026, month: 3, day: 15 });
+
+ expect(hostComponent.lastEmittedDate).toEqual({
+ year: '2026',
+ month: '03',
+ day: '15',
+ });
+
+ expect(component.showDatepicker()).toBeFalse();
+ });
+
+ it('should return null datepickerModel for incomplete date', () => {
+ hostComponent.date = { year: '2026', month: '', day: '' };
+ fixture.detectChanges();
+
+ expect(component.datepickerModel()).toBeNull();
+ });
+
+ it('should close datepicker on outside click', () => {
+ component.showDatepicker.set(true);
+ component.onDocumentClick({
+ target: document.body,
+ } as unknown as MouseEvent);
+
+ expect(component.showDatepicker()).toBeFalse();
+ });
+});
diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.ts
new file mode 100644
index 000000000..5cdb954fe
--- /dev/null
+++ b/src/app/shared/components/datepicker-input/datepicker-input.component.ts
@@ -0,0 +1,154 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ signal,
+ HostListener,
+ ElementRef,
+ ViewChild,
+ OnChanges,
+ SimpleChanges,
+ OnInit,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { NgbDatepicker, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
+import {
+ DateModel,
+ EdtfService,
+} from '@shared/services/edtf-service/edtf.service';
+
+@Component({
+ selector: 'pr-datepicker-input',
+ standalone: true,
+ imports: [CommonModule, NgbDatepicker],
+ templateUrl: './datepicker-input.component.html',
+ styleUrls: ['./datepicker-input.component.scss'],
+})
+export class DatepickerInputComponent implements OnInit, OnChanges {
+ @Input() date: DateModel = { year: '', month: '', day: '' };
+ @Input() disabled = false;
+
+ @Output() dateChange = new EventEmitter();
+
+ @ViewChild('yearInput') yearInput!: ElementRef;
+ @ViewChild('monthInput') monthInput!: ElementRef;
+ @ViewChild('dayInput') dayInput!: ElementRef;
+
+ showDatepicker = signal(false);
+ datepickerModel = signal(null);
+
+ constructor(
+ private elementRef: ElementRef,
+ private edtfService: EdtfService,
+ ) {}
+
+ ngOnInit(): void {
+ this.updateDatepickerModel(this.date);
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.date) {
+ this.updateDatepickerModel(this.date);
+ }
+ }
+
+ private updateDatepickerModel(date: DateModel): void {
+ const year = parseInt(date.year, 10);
+ const month = parseInt(date.month || '', 10);
+ const day = parseInt(date.day || '', 10);
+ if (year && month && day) {
+ this.datepickerModel.set({ year, month, day });
+ } else {
+ this.datepickerModel.set(null);
+ }
+ }
+
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent): void {
+ if (!this.elementRef.nativeElement.contains(event.target)) {
+ this.showDatepicker.set(false);
+ }
+ }
+
+ toggleDatepicker(): void {
+ if (this.disabled) return;
+ this.showDatepicker.update((v) => !v);
+ }
+
+ updateYear(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+
+ if (!this.edtfService.isValidYear(value)) {
+ input.value = this.date.year;
+ return;
+ }
+
+ this.dateChange.emit({ ...this.date, year: value });
+ if (value.length === 4) {
+ this.monthInput.nativeElement.focus();
+ }
+ }
+
+ updateMonth(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+
+ if (!this.edtfService.isValidMonth(value)) {
+ input.value = this.date.month ?? '';
+ return;
+ }
+
+ this.dateChange.emit({ ...this.date, month: value });
+ if (value.length === 2) {
+ this.dayInput.nativeElement.focus();
+ }
+ }
+
+ updateDay(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+
+ if (
+ !this.edtfService.isValidDay(value, this.date.year, this.date.month ?? '')
+ ) {
+ input.value = this.date.day ?? '';
+ return;
+ }
+
+ this.dateChange.emit({ ...this.date, day: value });
+ }
+
+ onMonthKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Backspace') return;
+ const target = event.target as HTMLInputElement;
+ if (target.value !== '') return;
+ event.preventDefault();
+ const newYear = (this.date.year ?? '').slice(0, -1);
+ this.dateChange.emit({ ...this.date, year: newYear });
+ this.yearInput.nativeElement.focus();
+ }
+
+ onDayKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Backspace') return;
+ const target = event.target as HTMLInputElement;
+ if (target.value !== '') return;
+ event.preventDefault();
+ const newMonth = (this.date.month ?? '').slice(0, -1);
+ this.dateChange.emit({ ...this.date, month: newMonth });
+ this.monthInput.nativeElement.focus();
+ }
+
+ onDateSelect(newDate: NgbDateStruct): void {
+ this.datepickerModel.set(newDate);
+ const updatedDate = {
+ year: String(newDate.year),
+ month: String(newDate.month).padStart(2, '0'),
+ day: String(newDate.day).padStart(2, '0'),
+ };
+ this.date = updatedDate;
+ this.dateChange.emit(updatedDate);
+ this.showDatepicker.set(false);
+ }
+}
diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.html b/src/app/shared/components/timepicker-input/timepicker-input.component.html
new file mode 100644
index 000000000..d23a855a1
--- /dev/null
+++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html
@@ -0,0 +1,62 @@
+
+
+@if (showTimepicker()) {
+
+
+
+}
diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.scss b/src/app/shared/components/timepicker-input/timepicker-input.component.scss
new file mode 100644
index 000000000..fc34d247b
--- /dev/null
+++ b/src/app/shared/components/timepicker-input/timepicker-input.component.scss
@@ -0,0 +1,162 @@
+@import 'colors';
+@import 'mixins';
+
+:host {
+ position: relative;
+ display: block;
+ height: 40px;
+}
+
+.pr-time-input-group {
+ @include input-container;
+ padding: 0 0 0 12px;
+
+ &:focus-within,
+ &.active {
+ @include input-focus-state;
+ }
+}
+
+.pr-time-segment {
+ @include input-segment;
+ width: 32px;
+}
+
+.pr-colon {
+ color: $PR-blue-400;
+ font-size: 14px;
+ margin: 0 2px;
+}
+
+.pr-time-icon-wrapper {
+ @include icon-wrapper;
+}
+
+.pr-am-pm-toggle {
+ background: $PR-blue-25;
+ border: 1px solid $PR-blue-100;
+ border-radius: 4px;
+ padding: 0 8px;
+ font-family: 'Usual', sans-serif;
+ font-weight: 400;
+ font-style: normal;
+ font-size: 10px;
+ line-height: 8px;
+ letter-spacing: 0.16em;
+ text-align: center;
+ text-transform: uppercase;
+ color: $PR-blue-900;
+ cursor: pointer;
+ margin-left: auto;
+ margin-right: 4px;
+ min-width: 40px;
+ height: 24px;
+ gap: 4px;
+ opacity: 1;
+
+ &:hover {
+ background: $PR-blue-100;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+}
+
+.pr-icon-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ color: $PR-blue-600;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ color: $PR-blue;
+ }
+
+ &.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .material-icons {
+ font-size: 20px;
+ }
+}
+
+.pr-timepicker-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10;
+ margin-top: 4px;
+ width: fit-content;
+ background: $white;
+ border: 1px solid $PR-blue-100;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba($black, 0.12);
+ padding: 12px;
+
+ ::ng-deep ngb-timepicker {
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .ngb-tp {
+ align-items: center;
+ }
+
+ .ngb-tp-input-container {
+ width: 4em;
+ }
+
+ .ngb-tp-input {
+ font-size: 14px;
+ text-align: center;
+ border: 1px solid $PR-blue-100;
+ border-radius: 6px;
+ padding: 4px;
+
+ &:focus {
+ border-color: $PR-blue;
+ outline: none;
+ }
+ }
+
+ .btn-link {
+ color: $PR-blue-600;
+
+ &:hover {
+ color: $PR-blue;
+ }
+ }
+
+ .ngb-tp-meridian {
+ button {
+ background: $PR-blue-50;
+ border: 1px solid $PR-blue-100;
+ border-radius: 4px;
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: $PR-blue;
+
+ &:hover {
+ background: $PR-blue-100;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts
new file mode 100644
index 000000000..a172e3a31
--- /dev/null
+++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts
@@ -0,0 +1,507 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { TimeModel } from '@shared/services/edtf-service/edtf.service';
+import { TimepickerInputComponent } from './timepicker-input.component';
+
+@Component({
+ template: ``,
+ standalone: true,
+ imports: [TimepickerInputComponent],
+})
+class TestHostComponent {
+ time: TimeModel = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'am',
+ };
+ disabled = false;
+ lastEmittedTime: TimeModel | null = null;
+
+ onTimeChange(time: TimeModel): void {
+ this.lastEmittedTime = time;
+ this.time = time;
+ }
+}
+
+describe('TimepickerInputComponent', () => {
+ let fixture: ComponentFixture;
+ let hostComponent: TestHostComponent;
+ let component: TimepickerInputComponent;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ hostComponent = fixture.componentInstance;
+ fixture.detectChanges();
+ component = fixture.debugElement.children[0].componentInstance;
+ });
+
+ const mockEvent = (value: string): Event =>
+ ({ target: { value } }) as unknown as Event;
+
+ // --- Basic rendering ---
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render time inputs', () => {
+ const inputs = fixture.nativeElement.querySelectorAll('.pr-time-segment');
+
+ expect(inputs.length).toBe(3);
+ });
+
+ // --- Disabled state ---
+
+ it('should not toggle timepicker when disabled', () => {
+ hostComponent.disabled = true;
+ fixture.detectChanges();
+ component.toggleTimepicker();
+
+ expect(component.showTimepicker()).toBeFalse();
+ });
+
+ // --- Timepicker toggle ---
+
+ it('should toggle timepicker', () => {
+ component.toggleTimepicker();
+
+ expect(component.showTimepicker()).toBeTrue();
+ component.toggleTimepicker();
+
+ expect(component.showTimepicker()).toBeFalse();
+ });
+
+ // --- Hour validation (12-hour) ---
+
+ it('should accept valid hour', () => {
+ component.updateTime(mockEvent('10'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('10');
+ });
+
+ it('should reject hour greater than 12', () => {
+ component.updateTime(mockEvent('13'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ it('should reject non-numeric hour', () => {
+ component.updateTime(mockEvent('ab'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ it('should accept single digit 0 or 1 for hours', () => {
+ component.updateTime(mockEvent('1'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('1');
+ });
+
+ it('should reject single digit greater than 1 for hours', () => {
+ component.updateTime(mockEvent('2'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ // --- Hour validation (24-hour) ---
+
+ it('should accept hours 00-23 in h24 mode', () => {
+ hostComponent.time = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ component.updateTime(mockEvent('00'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('00');
+
+ component.updateTime(mockEvent('13'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('13');
+
+ component.updateTime(mockEvent('23'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('23');
+ });
+
+ it('should reject hours 24 and above in h24 mode', () => {
+ hostComponent.time = {
+ hours: '12',
+ minutes: '',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+ hostComponent.lastEmittedTime = null;
+
+ component.updateTime(mockEvent('24'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+
+ component.updateTime(mockEvent('30'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ it('should accept single digit 0-2 for hours in h24 mode', () => {
+ hostComponent.time = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ component.updateTime(mockEvent('2'), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('2');
+ });
+
+ it('should reject single digit greater than 2 for hours in h24 mode', () => {
+ hostComponent.time = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ component.updateTime(mockEvent('3'), 'hours');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ // --- Minute validation ---
+
+ it('should accept valid minutes', () => {
+ component.updateTime(mockEvent('30'), 'minutes');
+
+ expect(hostComponent.lastEmittedTime?.minutes).toBe('30');
+ });
+
+ it('should reject minutes greater than 59', () => {
+ component.updateTime(mockEvent('60'), 'minutes');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ it('should accept single digit 0-5 for minutes', () => {
+ component.updateTime(mockEvent('5'), 'minutes');
+
+ expect(hostComponent.lastEmittedTime?.minutes).toBe('5');
+ });
+
+ it('should reject single digit greater than 5 for minutes', () => {
+ component.updateTime(mockEvent('6'), 'minutes');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ // --- Second validation ---
+
+ it('should accept valid seconds', () => {
+ component.updateTime(mockEvent('45'), 'seconds');
+
+ expect(hostComponent.lastEmittedTime?.seconds).toBe('45');
+ });
+
+ it('should reject seconds greater than 59', () => {
+ component.updateTime(mockEvent('60'), 'seconds');
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ // --- Empty values ---
+
+ it('should allow clearing fields', () => {
+ hostComponent.time = {
+ hours: '10',
+ minutes: '30',
+ seconds: '00',
+ format: 'am',
+ };
+ fixture.detectChanges();
+ component.updateTime(mockEvent(''), 'hours');
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('');
+ });
+
+ // --- Format cycle ---
+
+ it('should cycle AM -> PM', () => {
+ hostComponent.time = {
+ hours: '10',
+ minutes: '30',
+ seconds: '',
+ format: 'am',
+ };
+ fixture.detectChanges();
+ component.cycleFormat();
+
+ expect(hostComponent.lastEmittedTime?.format).toBe('pm');
+ });
+
+ it('should cycle PM -> h24', () => {
+ hostComponent.time = {
+ hours: '10',
+ minutes: '30',
+ seconds: '',
+ format: 'pm',
+ };
+ fixture.detectChanges();
+ component.cycleFormat();
+
+ expect(hostComponent.lastEmittedTime?.format).toBe('h24');
+ });
+
+ it('should cycle h24 -> AM', () => {
+ hostComponent.time = {
+ hours: '14',
+ minutes: '30',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+ component.cycleFormat();
+
+ expect(hostComponent.lastEmittedTime?.format).toBe('am');
+ });
+
+ it('should preserve hours value across format cycle', () => {
+ hostComponent.time = {
+ hours: '10',
+ minutes: '30',
+ seconds: '',
+ format: 'pm',
+ };
+ fixture.detectChanges();
+ component.cycleFormat();
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('10');
+ });
+
+ // --- Format label ---
+
+ it('should expose the AM label by default', () => {
+ expect(component.formatLabel()).toBe('AM');
+ expect(component.is24Hour()).toBeFalse();
+ });
+
+ it('should expose the 24H label when format is h24', () => {
+ hostComponent.time = {
+ hours: '14',
+ minutes: '30',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ expect(component.formatLabel()).toBe('24H');
+ expect(component.is24Hour()).toBeTrue();
+ });
+
+ it('should render formatLabel and bind meridian=false when h24 is active', () => {
+ hostComponent.time = {
+ hours: '14',
+ minutes: '30',
+ seconds: '',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ const toggleButton: HTMLButtonElement =
+ fixture.nativeElement.querySelector('.pr-am-pm-toggle');
+
+ expect(toggleButton.textContent?.trim()).toBe('24H');
+
+ component.toggleTimepicker();
+ fixture.detectChanges();
+
+ const ngbTimepicker = fixture.debugElement.query(
+ (node) => node.name === 'ngb-timepicker',
+ );
+
+ expect(ngbTimepicker.componentInstance.meridian).toBeFalse();
+ });
+
+ // --- Outside click ---
+
+ it('should close timepicker on outside click', () => {
+ component.showTimepicker.set(true);
+ component.onDocumentClick({
+ target: document.body,
+ } as unknown as MouseEvent);
+
+ expect(component.showTimepicker()).toBeFalse();
+ });
+
+ // --- FormControl sync ---
+
+ it('should sync FormControl from input time (PM)', () => {
+ hostComponent.time = {
+ hours: '02',
+ minutes: '30',
+ seconds: '15',
+ format: 'pm',
+ };
+ fixture.detectChanges();
+
+ expect(component.timepickerControl.value).toEqual({
+ hour: 14,
+ minute: 30,
+ second: 15,
+ });
+ });
+
+ it('should sync FormControl from input time (12 AM = 0)', () => {
+ hostComponent.time = {
+ hours: '12',
+ minutes: '00',
+ seconds: '00',
+ format: 'am',
+ };
+ fixture.detectChanges();
+
+ expect(component.timepickerControl.value).toEqual({
+ hour: 0,
+ minute: 0,
+ second: 0,
+ });
+ });
+
+ it('should sync FormControl from input time (12 PM = 12)', () => {
+ hostComponent.time = {
+ hours: '12',
+ minutes: '00',
+ seconds: '00',
+ format: 'pm',
+ };
+ fixture.detectChanges();
+
+ expect(component.timepickerControl.value).toEqual({
+ hour: 12,
+ minute: 0,
+ second: 0,
+ });
+ });
+
+ it('should set FormControl to midnight for empty time', () => {
+ hostComponent.time = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'am',
+ };
+ fixture.detectChanges();
+
+ expect(component.timepickerControl.value).toEqual({
+ hour: 0,
+ minute: 0,
+ second: 0,
+ });
+ });
+
+ it('should sync FormControl from input time (h24)', () => {
+ hostComponent.time = {
+ hours: '14',
+ minutes: '30',
+ seconds: '15',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+
+ expect(component.timepickerControl.value).toEqual({
+ hour: 14,
+ minute: 30,
+ second: 15,
+ });
+ });
+
+ // --- NgbTimepicker selection ---
+
+ it('should emit time on ngb-timepicker select for AM', () => {
+ component.onTimeSelect({ hour: 9, minute: 15, second: 30 });
+
+ expect(hostComponent.lastEmittedTime).toEqual({
+ hours: '09',
+ minutes: '15',
+ seconds: '30',
+ format: 'am',
+ });
+ });
+
+ it('should emit time on ngb-timepicker select for PM', () => {
+ component.onTimeSelect({ hour: 14, minute: 45, second: 0 });
+
+ expect(hostComponent.lastEmittedTime).toEqual({
+ hours: '02',
+ minutes: '45',
+ seconds: '00',
+ format: 'pm',
+ });
+ });
+
+ it('should emit 12 PM for hour 12', () => {
+ component.onTimeSelect({ hour: 12, minute: 0, second: 0 });
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('12');
+ expect(hostComponent.lastEmittedTime?.format).toBe('pm');
+ });
+
+ it('should emit 12 AM for hour 0', () => {
+ component.onTimeSelect({ hour: 0, minute: 0, second: 0 });
+
+ expect(hostComponent.lastEmittedTime?.hours).toBe('12');
+ expect(hostComponent.lastEmittedTime?.format).toBe('am');
+ });
+
+ it('should emit raw 24h hour when h24 is active', () => {
+ hostComponent.time = {
+ hours: '14',
+ minutes: '00',
+ seconds: '00',
+ format: 'h24',
+ };
+ fixture.detectChanges();
+ component.onTimeSelect({ hour: 14, minute: 30, second: 0 });
+
+ expect(hostComponent.lastEmittedTime).toEqual({
+ hours: '14',
+ minutes: '30',
+ seconds: '00',
+ format: 'h24',
+ });
+ });
+
+ it('should not emit on null ngb-timepicker value', () => {
+ component.onTimeSelect(null);
+
+ expect(hostComponent.lastEmittedTime).toBeNull();
+ });
+
+ it('should preserve the timezone offset on ngb-timepicker select', () => {
+ hostComponent.time = {
+ hours: '09',
+ minutes: '15',
+ seconds: '30',
+ format: 'am',
+ timezoneOffset: '+05:30',
+ };
+ fixture.detectChanges();
+ component.onTimeSelect({ hour: 14, minute: 45, second: 0 });
+
+ expect(hostComponent.lastEmittedTime?.timezoneOffset).toBe('+05:30');
+ });
+});
diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.ts
new file mode 100644
index 000000000..6ce01d446
--- /dev/null
+++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts
@@ -0,0 +1,189 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ signal,
+ computed,
+ HostListener,
+ OnInit,
+ OnChanges,
+ SimpleChanges,
+ OnDestroy,
+ ViewChild,
+ ElementRef,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ReactiveFormsModule, FormControl } from '@angular/forms';
+import { NgbTimepicker, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+import { Subject, takeUntil } from 'rxjs';
+import {
+ TimeModel,
+ TimeFormat,
+ TIME_FORMAT_LABEL,
+ DEFAULT_TIME,
+ EdtfService,
+} from '@shared/services/edtf-service/edtf.service';
+
+@Component({
+ selector: 'pr-timepicker-input',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule, NgbTimepicker],
+ templateUrl: './timepicker-input.component.html',
+ styleUrls: ['./timepicker-input.component.scss'],
+})
+export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy {
+ @Input() time: TimeModel = DEFAULT_TIME;
+ @Input() disabled = false;
+
+ @Output() timeChange = new EventEmitter();
+
+ @ViewChild('hoursInput') hoursInput!: ElementRef;
+ @ViewChild('minutesInput') minutesInput!: ElementRef;
+ @ViewChild('secondsInput') secondsInput?: ElementRef;
+
+ showTimepicker = signal(false);
+ timepickerControl = new FormControl(null);
+
+ private readonly timeSignal = signal(DEFAULT_TIME);
+ private readonly FORMAT_CYCLE: TimeFormat[] = ['am', 'pm', 'h24'];
+
+ formatLabel = computed(() => TIME_FORMAT_LABEL[this.timeSignal().format]);
+ is24Hour = computed(() => this.timeSignal().format === 'h24');
+
+ private destroy$ = new Subject();
+
+ constructor(
+ private elementRef: ElementRef,
+ private edtfService: EdtfService,
+ ) {}
+
+ ngOnInit(): void {
+ this.timepickerControl.valueChanges
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((ngbTime) => this.onTimeSelect(ngbTime));
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.time) {
+ this.timeSignal.set(this.time);
+ const model = this.edtfService.parseTimeAs24Hour(this.time);
+ const current = this.timepickerControl.value;
+ if (!this.ngbTimeEquals(model, current)) {
+ this.timepickerControl.setValue(model, { emitEvent: false });
+ }
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent): void {
+ if (!this.elementRef.nativeElement.contains(event.target)) {
+ this.showTimepicker.set(false);
+ }
+ }
+
+ toggleTimepicker(): void {
+ if (this.disabled) return;
+ this.showTimepicker.update((v) => !v);
+ }
+
+ onTimeSelect(ngbTime: NgbTimeStruct | null): void {
+ if (!ngbTime) return;
+
+ const minutes = String(ngbTime.minute).padStart(2, '0');
+ const seconds = String(ngbTime.second ?? 0).padStart(2, '0');
+
+ if (this.is24Hour()) {
+ this.timeChange.emit({
+ ...this.time,
+ hours: String(ngbTime.hour).padStart(2, '0'),
+ minutes,
+ seconds,
+ format: 'h24',
+ });
+ return;
+ }
+
+ const isPm = ngbTime.hour >= 12;
+ const displayHour = ngbTime.hour % 12 || 12;
+
+ this.timeChange.emit({
+ ...this.time,
+ hours: String(displayHour).padStart(2, '0'),
+ minutes,
+ seconds,
+ format: isPm ? 'pm' : 'am',
+ });
+ }
+
+ cycleFormat(): void {
+ const currentIndex = this.FORMAT_CYCLE.indexOf(this.time.format);
+ const nextFormat =
+ this.FORMAT_CYCLE[(currentIndex + 1) % this.FORMAT_CYCLE.length];
+ this.timeChange.emit({ ...this.time, format: nextFormat });
+ }
+
+ onMinutesKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Backspace') return;
+ const target = event.target as HTMLInputElement;
+ if (target.value !== '') return;
+ event.preventDefault();
+ const newHours = (this.time.hours ?? '').slice(0, -1);
+ this.timeChange.emit({ ...this.time, hours: newHours });
+ this.hoursInput.nativeElement.focus();
+ }
+
+ onSecondsKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Backspace') return;
+ const target = event.target as HTMLInputElement;
+ if (target.value !== '') return;
+ event.preventDefault();
+ const newMinutes = (this.time.minutes ?? '').slice(0, -1);
+ this.timeChange.emit({ ...this.time, minutes: newMinutes });
+ this.minutesInput.nativeElement.focus();
+ }
+
+ updateTime(
+ event: Event,
+ timePropKey: keyof Pick,
+ nextField?: HTMLInputElement,
+ ): void {
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+
+ if (value !== '') {
+ const isValid =
+ timePropKey === 'hours'
+ ? this.edtfService.isValidHour(value, this.is24Hour())
+ : this.edtfService.isValidMinutesSeconds(value);
+ if (!isValid) {
+ input.value = this.time[timePropKey] ?? '';
+ return;
+ }
+ }
+
+ this.timeChange.emit({ ...this.time, [timePropKey]: value });
+
+ if (nextField && value.length === 2) {
+ const isComplete =
+ timePropKey === 'hours'
+ ? this.edtfService.isValidHour(value, this.is24Hour())
+ : this.edtfService.isValidMinutesSeconds(value);
+ if (isComplete) nextField.focus();
+ }
+ }
+
+ private ngbTimeEquals(
+ a: NgbTimeStruct | null,
+ b: NgbTimeStruct | null,
+ ): boolean {
+ if (a === b) return true;
+ if (!a || !b) return false;
+ return a.hour === b.hour && a.minute === b.minute && a.second === b.second;
+ }
+}
diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html
new file mode 100644
index 000000000..8d161921b
--- /dev/null
+++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html
@@ -0,0 +1,57 @@
+
+
+ {{ selected?.offset }}
+ {{
+ selected?.label || 'Select timezone'
+ }}
+
+ unfold_more
+
+
+ @if (isOpen()) {
+
+
+
+
+ Select timezone
+
+ @for (tz of filteredTimezones(); track tz.ianaZone) {
+
+ {{ tz.offset }}
+ {{ tz.label }}
+
+ }
+
+
+ }
+
diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss
new file mode 100644
index 000000000..efda26819
--- /dev/null
+++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss
@@ -0,0 +1,107 @@
+@import 'colors';
+@import 'mixins';
+
+.pr-timezone-row {
+ position: relative;
+
+ .pr-timezone-select {
+ display: flex;
+ align-items: center;
+ background: $white;
+ border: 1px solid $PR-blue-100;
+ border-radius: 8px;
+ padding: 0 12px;
+ height: 40px;
+ cursor: pointer;
+
+ &:hover {
+ border-color: $PR-blue-300;
+ }
+
+ &.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .pr-timezone-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: $PR-blue-900;
+ margin-right: 8px;
+ }
+
+ .pr-timezone-label {
+ @include usual-text;
+ color: $PR-blue-400;
+ }
+
+ .pr-chevron {
+ margin-left: auto;
+ color: $PR-blue-400;
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .pr-timezone-dropdown {
+ position: absolute;
+ top: 52px;
+ left: 0;
+ right: 0;
+ background: $white;
+ border: 1px solid $PR-blue-100;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba($black, 0.12);
+ z-index: 10;
+ overflow: hidden;
+
+ .pr-timezone-search {
+ width: 100%;
+ padding: 10px 12px;
+ border: none;
+ border-bottom: 1px solid $PR-blue-100;
+ font-size: 14px;
+ outline: none;
+ color: $PR-blue;
+ box-sizing: border-box;
+
+ &::placeholder {
+ color: $PR-blue-400;
+ }
+ }
+
+ .pr-timezone-options {
+ max-height: 200px;
+ overflow-y: auto;
+ }
+
+ .pr-timezone-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ cursor: pointer;
+ transition: background 0.1s ease;
+
+ &:hover {
+ background: $PR-blue-25;
+ }
+
+ &.selected {
+ background: $PR-blue-25;
+ }
+
+ .pr-tz-offset {
+ font-size: 14px;
+ font-weight: 600;
+ color: $PR-blue-900;
+ min-width: 90px;
+ }
+
+ .pr-tz-name {
+ @include usual-text;
+ color: $PR-blue-400;
+ }
+ }
+ }
+}
diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts
new file mode 100644
index 000000000..fc65d302e
--- /dev/null
+++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts
@@ -0,0 +1,126 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ TimezoneDropdownComponent,
+ TimezoneOption,
+} from './timezone-dropdown.component';
+
+describe('TimezoneDropdownComponent', () => {
+ let component: TimezoneDropdownComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TimezoneDropdownComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TimezoneDropdownComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should source timezones from the IANA browser list', () => {
+ expect(component.timezones.length).toBeGreaterThan(0);
+ expect(
+ component.timezones.every((tz) => typeof tz.ianaZone === 'string'),
+ ).toBeTrue();
+ });
+
+ // --- Toggle behaviour ---
+
+ it('should open dropdown on toggle', () => {
+ expect(component.isOpen()).toBeFalse();
+ component.toggle();
+
+ expect(component.isOpen()).toBeTrue();
+ });
+
+ it('should close dropdown on second toggle', () => {
+ component.toggle();
+ component.toggle();
+
+ expect(component.isOpen()).toBeFalse();
+ });
+
+ it('should not open when disabled', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.detectChanges();
+ component.toggle();
+
+ expect(component.isOpen()).toBeFalse();
+ });
+
+ it('should reset filter when opening', () => {
+ component.filter.set('pacific');
+ component.toggle();
+
+ expect(component.filter()).toBe('');
+ });
+
+ it('should reset filter when closing', () => {
+ component.toggle();
+ component.filter.set('pacific');
+ component.close();
+
+ expect(component.filter()).toBe('');
+ });
+
+ // --- Filtering ---
+
+ it('should return all timezones when filter is empty', () => {
+ component.filter.set('');
+
+ expect(component.filteredTimezones().length).toBe(
+ component.timezones.length,
+ );
+ });
+
+ it('should filter timezones by IANA name', () => {
+ component.filter.set('pacific');
+ const filtered = component.filteredTimezones();
+
+ expect(filtered.length).toBeGreaterThan(0);
+ expect(
+ filtered.every(
+ (tz) =>
+ tz.ianaZone.toLowerCase().includes('pacific') ||
+ tz.label.toLowerCase().includes('pacific') ||
+ tz.abbreviation.toLowerCase().includes('pacific'),
+ ),
+ ).toBeTrue();
+ });
+
+ it('should filter timezones by offset', () => {
+ component.filter.set('gmt+09');
+ const filtered = component.filteredTimezones();
+
+ expect(filtered.length).toBeGreaterThan(0);
+ expect(
+ filtered.every((tz) => tz.offset.toLowerCase().includes('gmt+09')),
+ ).toBeTrue();
+ });
+
+ // --- Selection ---
+
+ it('should emit timezoneChange and close on select', () => {
+ spyOn(component.timezoneChange, 'emit');
+ component.toggle();
+
+ const tz: TimezoneOption = component.timezones[0];
+ component.select(tz);
+
+ expect(component.timezoneChange.emit).toHaveBeenCalledWith(tz);
+ expect(component.isOpen()).toBeFalse();
+ expect(component.filter()).toBe('');
+ });
+
+ it('should emit null for placeholder selection', () => {
+ spyOn(component.timezoneChange, 'emit');
+ component.select(null);
+
+ expect(component.timezoneChange.emit).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts
new file mode 100644
index 000000000..a059e84ad
--- /dev/null
+++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts
@@ -0,0 +1,118 @@
+import {
+ Component,
+ Input,
+ Output,
+ EventEmitter,
+ signal,
+ computed,
+ ElementRef,
+ ViewChild,
+ HostListener,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+export interface TimezoneOption {
+ ianaZone: string;
+ label: string;
+ offset: string;
+ abbreviation: string;
+}
+
+function getSupportedIanaZones(): string[] {
+ const supportedValuesOf = (
+ Intl as unknown as {
+ supportedValuesOf?: (key: string) => string[];
+ }
+ ).supportedValuesOf;
+ return typeof supportedValuesOf === 'function'
+ ? supportedValuesOf('timeZone')
+ : [];
+}
+
+function extractTimeZoneNamePart(
+ ianaZone: string,
+ timeZoneName: 'longOffset' | 'short',
+ referenceDate: Date,
+): string {
+ try {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: ianaZone,
+ timeZoneName,
+ }).formatToParts(referenceDate);
+ return parts.find((part) => part.type === 'timeZoneName')?.value ?? '';
+ } catch {
+ return '';
+ }
+}
+
+function buildTimezoneOptions(): TimezoneOption[] {
+ const referenceDate = new Date();
+ return getSupportedIanaZones().map((ianaZone) => ({
+ ianaZone,
+ label: ianaZone.replace(/_/g, ' ').replace(/\//g, ' / '),
+ offset: extractTimeZoneNamePart(ianaZone, 'longOffset', referenceDate),
+ abbreviation: extractTimeZoneNamePart(ianaZone, 'short', referenceDate),
+ }));
+}
+
+const TIMEZONE_OPTIONS: TimezoneOption[] = buildTimezoneOptions();
+
+@Component({
+ selector: 'pr-timezone-dropdown',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './timezone-dropdown.component.html',
+ styleUrls: ['./timezone-dropdown.component.scss'],
+})
+export class TimezoneDropdownComponent {
+ @Input() selected: TimezoneOption | null = null;
+ @Input() disabled = false;
+ @Output() timezoneChange = new EventEmitter();
+
+ @ViewChild('dropdownContainer') dropdownContainer?: ElementRef;
+
+ isOpen = signal(false);
+ filter = signal('');
+
+ timezones: TimezoneOption[] = TIMEZONE_OPTIONS;
+
+ filteredTimezones = computed(() => {
+ const term = this.filter().toLowerCase();
+ if (!term) return this.timezones;
+ return this.timezones.filter(
+ (tz) =>
+ tz.ianaZone.toLowerCase().includes(term) ||
+ tz.label.toLowerCase().includes(term) ||
+ tz.offset.toLowerCase().includes(term) ||
+ tz.abbreviation.toLowerCase().includes(term),
+ );
+ });
+
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent): void {
+ const target = event.target as Node;
+ if (!this.dropdownContainer?.nativeElement.contains(target)) {
+ this.close();
+ }
+ }
+
+ toggle(): void {
+ if (this.disabled) return;
+ if (this.isOpen()) {
+ this.close();
+ } else {
+ this.isOpen.set(true);
+ this.filter.set('');
+ }
+ }
+
+ close(): void {
+ this.isOpen.set(false);
+ this.filter.set('');
+ }
+
+ select(tz: TimezoneOption | null): void {
+ this.timezoneChange.emit(tz);
+ this.close();
+ }
+}
diff --git a/src/app/shared/services/edtf-service/edtf.service.spec.ts b/src/app/shared/services/edtf-service/edtf.service.spec.ts
index 20150dcb6..f4ea9e906 100644
--- a/src/app/shared/services/edtf-service/edtf.service.spec.ts
+++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts
@@ -1,5 +1,16 @@
import { EdtfService, DateTimeModel } from './edtf.service';
+// Mirrors the service's local-offset stamping so the expectations stay
+// green in any timezone the tests run in.
+const localTimezoneOffset = (): string => {
+ const offsetMinutes = -new Date().getTimezoneOffset();
+ const sign = offsetMinutes < 0 ? '-' : '+';
+ const absoluteMinutes = Math.abs(offsetMinutes);
+ const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0');
+ const minutes = String(absoluteMinutes % 60).padStart(2, '0');
+ return `${sign}${hours}:${minutes}`;
+};
+
describe('EdtfService', () => {
let service: EdtfService;
@@ -13,8 +24,8 @@ describe('EdtfService', () => {
const result = service.toDateTimeModel('1985');
expect(result.date.year).toBe('1985');
- expect(result.date.month).toBeUndefined();
- expect(result.date.day).toBeUndefined();
+ expect(result.date.month).toBe('');
+ expect(result.date.day).toBe('');
});
it('should parse year-month', () => {
@@ -22,7 +33,7 @@ describe('EdtfService', () => {
expect(result.date.year).toBe('1985');
expect(result.date.month).toBe('05');
- expect(result.date.day).toBeUndefined();
+ expect(result.date.day).toBe('');
});
it('should parse full date', () => {
@@ -34,36 +45,20 @@ describe('EdtfService', () => {
});
});
- describe('partial year', () => {
- it('should return only known digits for partial year', () => {
- const result = service.toDateTimeModel('19XX');
-
- expect(result.date.year).toBe('19');
- expect(result.date.month).toBeUndefined();
- expect(result.date.day).toBeUndefined();
- });
-
- it('should return single digit for mostly unknown year', () => {
- const result = service.toDateTimeModel('1XXX');
-
- expect(result.date.year).toBe('1');
- });
- });
-
describe('unspecified fields', () => {
- it('should set month to undefined when XX', () => {
+ it('should set month to empty when XX', () => {
const result = service.toDateTimeModel('1985-XX');
expect(result.date.year).toBe('1985');
- expect(result.date.month).toBeUndefined();
+ expect(result.date.month).toBe('');
});
- it('should set day to undefined when XX', () => {
+ it('should set day to empty when XX', () => {
const result = service.toDateTimeModel('1985-05-XX');
expect(result.date.year).toBe('1985');
expect(result.date.month).toBe('05');
- expect(result.date.day).toBeUndefined();
+ expect(result.date.day).toBe('');
});
it('should set unknown qualifier to true when whole date is unspecified', () => {
@@ -72,6 +67,16 @@ describe('EdtfService', () => {
expect(result.qualifiers.unknown).toBe(true);
});
+ it('should parse XXXX-XX-XX as unknown', () => {
+ const result = service.toDateTimeModel('XXXX-XX-XX');
+
+ expect(result.qualifiers.unknown).toBe(true);
+ expect(result.date.year).toBe('');
+ expect(result.date.month).toBe('');
+ expect(result.date.day).toBe('');
+ expect(result.time.hours).toBe('');
+ });
+
it('should set unknown qualifier to false when partially unspecified', () => {
const result = service.toDateTimeModel('1985-XX');
@@ -83,6 +88,43 @@ describe('EdtfService', () => {
expect(result.qualifiers.unknown).toBe(false);
});
+
+ it('should strip trailing X from partial year', () => {
+ const result = service.toDateTimeModel('198X');
+
+ expect(result.date.year).toBe('198');
+ expect(result.date.month).toBe('');
+ });
+
+ it('should strip trailing X from partial month', () => {
+ const result = service.toDateTimeModel('1985-1X');
+
+ expect(result.date.year).toBe('1985');
+ expect(result.date.month).toBe('1');
+ });
+
+ it('should strip trailing X from partial day', () => {
+ const result = service.toDateTimeModel('1985-05-2X');
+
+ expect(result.date.year).toBe('1985');
+ expect(result.date.month).toBe('05');
+ expect(result.date.day).toBe('2');
+ });
+
+ it('should parse XXXX-05 as month-only', () => {
+ const result = service.toDateTimeModel('XXXX-05');
+
+ expect(result.date.year).toBe('');
+ expect(result.date.month).toBe('05');
+ });
+
+ it('should parse 198X-05-20 as partial year with full month and day', () => {
+ const result = service.toDateTimeModel('198X-05-20');
+
+ expect(result.date.year).toBe('198');
+ expect(result.date.month).toBe('05');
+ expect(result.date.day).toBe('20');
+ });
});
describe('time parsing', () => {
@@ -92,72 +134,102 @@ describe('EdtfService', () => {
expect(result.time.hours).toBe('02');
expect(result.time.minutes).toBe('30');
expect(result.time.seconds).toBe('45');
- expect(result.time.pm).toBe(true);
- expect(result.time.am).toBe(false);
+ expect(result.time.format).toBe('pm');
});
it('should parse AM time', () => {
const result = service.toDateTimeModel('1985-05-20T09:15:00Z');
expect(result.time.hours).toBe('09');
- expect(result.time.am).toBe(true);
- expect(result.time.pm).toBe(false);
+ expect(result.time.format).toBe('am');
});
it('should parse midnight as 12 AM', () => {
const result = service.toDateTimeModel('1985-05-20T00:00:00Z');
expect(result.time.hours).toBe('12');
- expect(result.time.am).toBe(true);
- expect(result.time.pm).toBe(false);
+ expect(result.time.format).toBe('am');
});
it('should parse noon as 12 PM', () => {
const result = service.toDateTimeModel('1985-05-20T12:00:00Z');
expect(result.time.hours).toBe('12');
- expect(result.time.am).toBe(false);
- expect(result.time.pm).toBe(true);
+ expect(result.time.format).toBe('pm');
});
- it('should have undefined time fields when no time present', () => {
+ it('should have empty time fields when no time present', () => {
const result = service.toDateTimeModel('1985-05-20');
- expect(result.time.hours).toBeUndefined();
- expect(result.time.minutes).toBeUndefined();
- expect(result.time.seconds).toBeUndefined();
- expect(result.time.am).toBeUndefined();
- expect(result.time.pm).toBeUndefined();
+ expect(result.time.hours).toBe('');
+ expect(result.time.minutes).toBe('');
+ expect(result.time.seconds).toBe('');
+ expect(result.time.format).toBe('am');
});
- });
- describe('timezone', () => {
- it('should extract UTC timezone from Z suffix', () => {
- const result = service.toDateTimeModel('1985-05-20T14:30:45Z');
+ it('should parse time with a timezone offset suffix as local wall-clock time', () => {
+ const result = service.toDateTimeModel('1985-05-20T14:30:45+05:30');
- expect(result.time.timezoneOffset).toBe('+00:00');
- expect(result.time.timezoneName).toBe('UTC');
+ expect(result.time.hours).toBe('02');
+ expect(result.time.minutes).toBe('30');
+ expect(result.time.seconds).toBe('45');
+ expect(result.time.format).toBe('pm');
});
- it('should extract positive timezone offset', () => {
+ it('should preserve the timezone offset suffix in the model', () => {
const result = service.toDateTimeModel('1985-05-20T14:30:45+05:30');
expect(result.time.timezoneOffset).toBe('+05:30');
- expect(result.time.timezoneName).toBe('');
});
- it('should extract negative timezone offset', () => {
- const result = service.toDateTimeModel('1985-05-20T14:30:45-04:00');
+ it('should not set a timezone offset for unmarked or Z-marked times', () => {
+ const unmarked = service.toDateTimeModel('1985-05-20T14:30:45');
+ const utcMarked = service.toDateTimeModel('1985-05-20T14:30:45Z');
- expect(result.time.timezoneOffset).toBe('-04:00');
- expect(result.time.timezoneName).toBe('');
+ expect(unmarked.time.timezoneOffset).toBeUndefined();
+ expect(utcMarked.time.timezoneOffset).toBeUndefined();
});
- it('should have empty timezone when none present', () => {
- const result = service.toDateTimeModel('1985-05-20');
+ it('should preserve the date parts of an early-morning unmarked time', () => {
+ // Regression: without normalization the edtf library treats the
+ // value as browser-local and converts it to UTC, which can shift
+ // the date parts across midnight.
+ const result = service.toDateTimeModel('1985-05-20T01:00:00');
- expect(result.time.timezoneOffset).toBe('');
- expect(result.time.timezoneName).toBe('');
+ expect(result.date.year).toBe('1985');
+ expect(result.date.month).toBe('05');
+ expect(result.date.day).toBe('20');
+ expect(result.time.hours).toBe('01');
+ });
+
+ it('should preserve the date parts of an early-morning offset-marked time', () => {
+ const result = service.toDateTimeModel('1985-05-20T01:00:00+05:30');
+
+ expect(result.date.day).toBe('20');
+ expect(result.time.hours).toBe('01');
+ });
+
+ it('should preserve wall-clock time when no timezone marker is present', () => {
+ // Regression: edtf treats unmarked datetimes as local time and
+ // shifts them to UTC, which mangles the displayed wall-clock
+ // value in any non-UTC timezone.
+ const result = service.toDateTimeModel('1985-05-20T23:23:23');
+
+ expect(result.time.hours).toBe('11');
+ expect(result.time.minutes).toBe('23');
+ expect(result.time.seconds).toBe('23');
+ expect(result.time.format).toBe('pm');
+ });
+
+ it('should preserve wall-clock time when input has milliseconds and Z', () => {
+ // The folder/record VO normalizer rewrites BE values like
+ // '1985-05-20T23:23:23' to '1985-05-20T23:23:23.000Z'.
+ const result = service.toDateTimeModel('1985-05-20T23:23:23.000Z');
+
+ expect(result.time.hours).toBe('11');
+ expect(result.time.minutes).toBe('23');
+ expect(result.time.seconds).toBe('23');
+ expect(result.time.format).toBe('pm');
});
});
@@ -217,36 +289,20 @@ describe('EdtfService', () => {
const result = service.toDateTimeModel('1985/1990');
expect(result.date.year).toBe('1985');
- expect(result.date.month).toBeUndefined();
+ expect(result.date.month).toBe('');
expect(result.endDate.year).toBe('1990');
- expect(result.endDate.month).toBeUndefined();
- });
-
- it('should parse open start interval', () => {
- const result = service.toDateTimeModel('../1985');
-
- expect(result.date.year).toBe('');
- expect(result.date.month).toBeUndefined();
- expect(result.time.hours).toBeUndefined();
- expect(result.endDate.year).toBe('1985');
+ expect(result.endDate.month).toBe('');
});
- it('should parse open end interval', () => {
- const result = service.toDateTimeModel('1985/..');
+ it('should parse an interval with timezone suffixes and preserve each offset', () => {
+ const result = service.toDateTimeModel(
+ '1985-05-20T10:00:00+05:30/1990-06-15T12:00:00-04:00',
+ );
expect(result.date.year).toBe('1985');
- expect(result.endDate.year).toBe('');
- expect(result.endDate.month).toBeUndefined();
- expect(result.endTime.hours).toBeUndefined();
- });
-
- it('should parse open start with full end date', () => {
- const result = service.toDateTimeModel('../1985-05-20');
-
- expect(result.date.year).toBe('');
- expect(result.endDate.year).toBe('1985');
- expect(result.endDate.month).toBe('05');
- expect(result.endDate.day).toBe('20');
+ expect(result.endDate.year).toBe('1990');
+ expect(result.time.timezoneOffset).toBe('+05:30');
+ expect(result.endTime.timezoneOffset).toBe('-04:00');
});
});
});
@@ -256,7 +312,7 @@ describe('EdtfService', () => {
it('should build year-only EDTF string', () => {
const model: DateTimeModel = {
date: { year: '1985' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985');
@@ -265,7 +321,7 @@ describe('EdtfService', () => {
it('should build year-month EDTF string', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985-05');
@@ -274,30 +330,84 @@ describe('EdtfService', () => {
it('should build full date EDTF string', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05', day: '20' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985-05-20');
});
});
- describe('partial year output', () => {
- it('should pad partial year with X', () => {
+ describe('unspecified-digit (X-padding)', () => {
+ it('should pad 3-digit year with one X', () => {
+ const model: DateTimeModel = {
+ date: { year: '198' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('198X');
+ });
+
+ it('should pad 2-digit year with two Xs', () => {
const model: DateTimeModel = {
date: { year: '19' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('19XX');
});
- it('should pad single digit year with X', () => {
+ it('should emit XXXX when only month has digits', () => {
+ const model: DateTimeModel = {
+ date: { year: '', month: '05' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('XXXX-05');
+ });
+
+ it('should emit XXXX-XX-20 when only day has digits', () => {
+ const model: DateTimeModel = {
+ date: { year: '', month: '', day: '20' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('XXXX-XX-20');
+ });
+
+ it('should emit 1985-XX-20 when month is missing but day is present', () => {
const model: DateTimeModel = {
- date: { year: '1' },
- time: { timezoneOffset: '', timezoneName: '' },
+ date: { year: '1985', month: '', day: '20' },
+ time: { format: 'am' },
};
- expect(service.toEdtfDate(model)).toBe('1XXX');
+ expect(service.toEdtfDate(model)).toBe('1985-XX-20');
+ });
+
+ it('should pad single-digit day with one X', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05', day: '2' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('1985-05-2X');
+ });
+
+ it('should pad single-digit month with one X', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '1' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('1985-1X');
+ });
+
+ it('should combine partial year, full month, and full day', () => {
+ const model: DateTimeModel = {
+ date: { year: '198', month: '05', day: '20' },
+ time: { format: 'am' },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('198X-05-20');
});
});
@@ -309,10 +419,7 @@ describe('EdtfService', () => {
hours: '02',
minutes: '30',
seconds: '45',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
+ format: 'pm',
},
};
@@ -328,10 +435,7 @@ describe('EdtfService', () => {
hours: '09',
minutes: '15',
seconds: '00',
- am: true,
- pm: false,
- timezoneOffset: '',
- timezoneName: '',
+ format: 'am',
},
};
@@ -347,10 +451,7 @@ describe('EdtfService', () => {
hours: '12',
minutes: '00',
seconds: '00',
- am: true,
- pm: false,
- timezoneOffset: '',
- timezoneName: '',
+ format: 'am',
},
};
@@ -366,10 +467,7 @@ describe('EdtfService', () => {
hours: '12',
minutes: '00',
seconds: '00',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
+ format: 'pm',
},
};
@@ -378,66 +476,64 @@ describe('EdtfService', () => {
expect(result).toContain('T12:00:00');
});
+ it('should build a date with a raw 24-hour time', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05', day: '20' },
+ time: {
+ hours: '14',
+ minutes: '30',
+ seconds: '45',
+ format: 'h24',
+ },
+ };
+
+ const result = service.toEdtfDate(model);
+
+ expect(result).toContain('T14:30:45');
+ });
+
it('should omit time when hours not provided', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05', day: '20' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
};
const result = service.toEdtfDate(model);
expect(result).not.toContain('T');
});
- });
- describe('timezone output', () => {
- it('should append timezone offset', () => {
+ it('should preserve the timezone offset stored in the model', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05', day: '20' },
time: {
hours: '02',
minutes: '30',
seconds: '45',
- am: false,
- pm: true,
+ format: 'pm',
timezoneOffset: '+05:30',
- timezoneName: '',
},
};
const result = service.toEdtfDate(model);
- expect(result).toContain('+05:30');
+ expect(result).toBe('1985-05-20T14:30:45+05:30');
});
- it('should append negative timezone offset', () => {
+ it('should stamp the local timezone offset when the model has none', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05', day: '20' },
time: {
- hours: '09',
- minutes: '00',
- seconds: '00',
- am: true,
- pm: false,
- timezoneOffset: '-04:00',
- timezoneName: '',
+ hours: '02',
+ minutes: '30',
+ seconds: '45',
+ format: 'pm',
},
};
const result = service.toEdtfDate(model);
- expect(result).toContain('-04:00');
- });
-
- it('should not append timezone when empty', () => {
- const model: DateTimeModel = {
- date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
- };
-
- const result = service.toEdtfDate(model);
-
- expect(result).toBe('1985-05');
+ expect(result).toBe(`1985-05-20T14:30:45${localTimezoneOffset()}`);
});
});
@@ -445,7 +541,7 @@ describe('EdtfService', () => {
it('should add approximate qualifier', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
qualifiers: { approximate: true, uncertain: false, unknown: false },
};
@@ -455,7 +551,7 @@ describe('EdtfService', () => {
it('should add uncertain qualifier', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
qualifiers: { approximate: false, uncertain: true, unknown: false },
};
@@ -465,7 +561,7 @@ describe('EdtfService', () => {
it('should add combined qualifier', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
qualifiers: { approximate: true, uncertain: true, unknown: false },
};
@@ -475,21 +571,31 @@ describe('EdtfService', () => {
it('should not add qualifier when none set', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
qualifiers: { approximate: false, uncertain: false, unknown: false },
};
expect(service.toEdtfDate(model)).toBe('1985-05');
});
+
+ it('should return XXXX-XX-XX when unknown qualifier is set', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05', day: '20' },
+ time: { format: 'am' },
+ qualifiers: { approximate: false, uncertain: false, unknown: true },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('XXXX-XX-XX');
+ });
});
describe('interval output (range)', () => {
it('should build a date range', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
endDate: { year: '1990', month: '06' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ endTime: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985-05/1990-06');
@@ -498,9 +604,9 @@ describe('EdtfService', () => {
it('should build a full date range', () => {
const model: DateTimeModel = {
date: { year: '1985', month: '05', day: '20' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
endDate: { year: '1990', month: '06', day: '15' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ endTime: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985-05-20/1990-06-15');
@@ -509,197 +615,416 @@ describe('EdtfService', () => {
it('should build a year-only range', () => {
const model: DateTimeModel = {
date: { year: '1985' },
- time: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
endDate: { year: '1990' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ endTime: { format: 'am' },
};
expect(service.toEdtfDate(model)).toBe('1985/1990');
});
- it('should build open start interval', () => {
+ it('should apply approximate qualifier to both dates in a range', () => {
const model: DateTimeModel = {
- date: { year: '' },
- time: { timezoneOffset: '', timezoneName: '' },
- endDate: { year: '1985' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ date: { year: '1985', month: '05' },
+ time: { format: 'am' },
+ endDate: { year: '1990', month: '06' },
+ endTime: { format: 'am' },
+ qualifiers: { approximate: true, uncertain: false, unknown: false },
};
- expect(service.toEdtfDate(model)).toBe('../1985');
+ expect(service.toEdtfDate(model)).toBe('1985-05~/1990-06~');
});
- it('should build open end interval', () => {
+ it('should apply uncertain qualifier to both dates in a range', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05' },
+ time: { format: 'am' },
+ endDate: { year: '1990', month: '06' },
+ endTime: { format: 'am' },
+ qualifiers: { approximate: false, uncertain: true, unknown: false },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('1985-05?/1990-06?');
+ });
+
+ it('should apply combined qualifier to both dates in a range', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05' },
+ time: { format: 'am' },
+ endDate: { year: '1990', month: '06' },
+ endTime: { format: 'am' },
+ qualifiers: { approximate: true, uncertain: true, unknown: false },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('1985-05%/1990-06%');
+ });
+
+ it('should apply qualifier only to the start when the end is open', () => {
+ const model: DateTimeModel = {
+ date: { year: '1985', month: '05' },
+ time: { format: 'am' },
+ endDate: { year: '', month: '', day: '' },
+ endTime: { format: 'am' },
+ qualifiers: { approximate: true, uncertain: false, unknown: false },
+ };
+
+ expect(service.toEdtfDate(model)).toBe('1985-05~/..');
+ });
+
+ it('should apply qualifier to both dates with mixed precision', () => {
const model: DateTimeModel = {
date: { year: '1985' },
- time: { timezoneOffset: '', timezoneName: '' },
- endDate: { year: '' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ time: { format: 'am' },
+ endDate: { year: '1990', month: '06' },
+ endTime: { format: 'am' },
+ qualifiers: { approximate: true, uncertain: false, unknown: false },
};
- expect(service.toEdtfDate(model)).toBe('1985/..');
+ expect(service.toEdtfDate(model)).toBe('1985~/1990-06~');
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ describe('parsing errors', () => {
+ it('should throw a human-readable error for completely invalid EDTF string', () => {
+ expect(() => service.toDateTimeModel('not-a-date')).toThrowError();
+ });
+
+ it('should return null for invalid interval string', () => {
+ const result = service.toDateTimeModel('invalid/also-invalid');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null for empty string', () => {
+ const result = service.toDateTimeModel('');
+
+ expect(result).toBeNull();
});
+ });
- it('should build fully open interval', () => {
+ describe('formatting errors', () => {
+ it('should throw a generic human-readable error for invalid dates', () => {
const model: DateTimeModel = {
- date: { year: '' },
- time: { timezoneOffset: '', timezoneName: '' },
- endDate: { year: '' },
- endTime: { timezoneOffset: '', timezoneName: '' },
+ date: { year: '1985', month: '99' },
+ time: { format: 'am' },
};
- expect(service.toEdtfDate(model)).toBe('../..');
+ expect(() => service.toEdtfDate(model)).toThrowError(
+ /Please check the values/,
+ );
});
});
});
- describe('isValidDate', () => {
- it('should return true for a valid full date', () => {
- expect(
- service.isValidDate({ year: '1985', month: '05', day: '20' }),
- ).toBe(true);
+ describe('parseTimeAs24Hour', () => {
+ it('should convert PM time to 24-hour format', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '02',
+ minutes: '30',
+ seconds: '45',
+ format: 'pm',
+ });
+
+ expect(result).toEqual({ hour: 14, minute: 30, second: 45 });
});
- it('should return true for a valid year-month', () => {
- expect(service.isValidDate({ year: '1985', month: '05', day: '' })).toBe(
- true,
- );
+ it('should convert AM time to 24-hour format', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '09',
+ minutes: '15',
+ seconds: '00',
+ format: 'am',
+ });
+
+ expect(result).toEqual({ hour: 9, minute: 15, second: 0 });
});
- it('should return true for a valid year only', () => {
- expect(service.isValidDate({ year: '1985', month: '', day: '' })).toBe(
- true,
- );
+ it('should convert 12 PM to 12', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '12',
+ minutes: '00',
+ seconds: '00',
+ format: 'pm',
+ });
+
+ expect(result).toEqual({ hour: 12, minute: 0, second: 0 });
});
- it('should return true for a partial year', () => {
- expect(service.isValidDate({ year: '19', month: '', day: '' })).toBe(
- true,
- );
+ it('should convert 12 AM to 0', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '12',
+ minutes: '00',
+ seconds: '00',
+ format: 'am',
+ });
+
+ expect(result).toEqual({ hour: 0, minute: 0, second: 0 });
+ });
+
+ it('should default missing seconds to 0', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '05',
+ minutes: '30',
+ format: 'am',
+ });
+
+ expect(result.second).toBe(0);
+ });
+
+ it('should default missing hours and minutes to 0', () => {
+ const result = service.parseTimeAs24Hour({ format: 'am' });
+
+ expect(result).toEqual({ hour: 0, minute: 0, second: 0 });
+ });
+
+ it('should parse unpadded single-digit hours', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '7',
+ minutes: '5',
+ seconds: '3',
+ format: 'am',
+ });
+
+ expect(result).toEqual({ hour: 7, minute: 5, second: 3 });
});
- it('should return false for an invalid month', () => {
- expect(service.isValidDate({ year: '1985', month: '13', day: '' })).toBe(
- false,
- );
+ it('should pass through 24h hours directly', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '14',
+ minutes: '30',
+ seconds: '45',
+ format: 'h24',
+ });
+
+ expect(result).toEqual({ hour: 14, minute: 30, second: 45 });
});
- it('should return false for an invalid day', () => {
- expect(
- service.isValidDate({ year: '1985', month: '05', day: '32' }),
- ).toBe(false);
+ it('should handle 00 hours in h24 mode', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '00',
+ minutes: '00',
+ seconds: '00',
+ format: 'h24',
+ });
+
+ expect(result).toEqual({ hour: 0, minute: 0, second: 0 });
});
- it('should return false for an empty year', () => {
- expect(service.isValidDate({ year: '', month: '', day: '' })).toBe(false);
+ it('should handle 23 hours in h24 mode', () => {
+ const result = service.parseTimeAs24Hour({
+ hours: '23',
+ minutes: '59',
+ seconds: '59',
+ format: 'h24',
+ });
+
+ expect(result).toEqual({ hour: 23, minute: 59, second: 59 });
});
});
- describe('isValidTime', () => {
- it('should return true for a valid PM time', () => {
- expect(
- service.isValidTime({
- hours: '02',
- minutes: '30',
- seconds: '45',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(true);
- });
-
- it('should return true for a valid AM time', () => {
- expect(
- service.isValidTime({
- hours: '09',
- minutes: '15',
- seconds: '00',
- am: true,
- pm: false,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(true);
- });
-
- it('should return true for midnight (12 AM)', () => {
- expect(
- service.isValidTime({
- hours: '12',
- minutes: '00',
- seconds: '00',
- am: true,
- pm: false,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(true);
- });
-
- it('should return true for noon (12 PM)', () => {
- expect(
- service.isValidTime({
- hours: '12',
- minutes: '00',
- seconds: '00',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(true);
- });
-
- it('should return true when no hours provided', () => {
- expect(
- service.isValidTime({
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(true);
- });
-
- it('should return false for invalid hours', () => {
- expect(
- service.isValidTime({
- hours: '13',
- minutes: '00',
- seconds: '00',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(false);
- });
-
- it('should return false for invalid minutes', () => {
- expect(
- service.isValidTime({
- hours: '02',
- minutes: '60',
- seconds: '00',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(false);
- });
-
- it('should return false for invalid seconds', () => {
- expect(
- service.isValidTime({
- hours: '02',
- minutes: '30',
- seconds: '60',
- am: false,
- pm: true,
- timezoneOffset: '',
- timezoneName: '',
- }),
- ).toBe(false);
+ describe('isValidHour', () => {
+ it('should validate single digit hour', () => {
+ expect(service.isValidHour('0')).toBe(true);
+ expect(service.isValidHour('1')).toBe(true);
+ expect(service.isValidHour('2')).toBe(false);
+ });
+
+ it('should return true for valid 2-digit hour 01', () => {
+ expect(service.isValidHour('01')).toBe(true);
+ expect(service.isValidHour('12')).toBe(true);
+ });
+
+ it('should return false for hour 13', () => {
+ expect(service.isValidHour('13')).toBe(false);
+ });
+
+ it('should return false for hour 00', () => {
+ expect(service.isValidHour('00')).toBe(false);
+ });
+
+ it('should return false for non-numeric input', () => {
+ expect(service.isValidHour('ab')).toBe(false);
+ });
+
+ it('should return false for input longer than 2 digits', () => {
+ expect(service.isValidHour('123')).toBe(false);
+ });
+
+ describe('h24 mode', () => {
+ it('should accept single digits 0-2', () => {
+ expect(service.isValidHour('0', true)).toBe(true);
+ expect(service.isValidHour('1', true)).toBe(true);
+ expect(service.isValidHour('2', true)).toBe(true);
+ });
+
+ it('should reject single digit greater than 2', () => {
+ expect(service.isValidHour('3', true)).toBe(false);
+ expect(service.isValidHour('9', true)).toBe(false);
+ });
+
+ it('should accept 2-digit hours 00-23', () => {
+ expect(service.isValidHour('00', true)).toBe(true);
+ expect(service.isValidHour('13', true)).toBe(true);
+ expect(service.isValidHour('23', true)).toBe(true);
+ });
+
+ it('should reject hours 24 and above', () => {
+ expect(service.isValidHour('24', true)).toBe(false);
+ expect(service.isValidHour('30', true)).toBe(false);
+ expect(service.isValidHour('99', true)).toBe(false);
+ });
+
+ it('should reject non-numeric input', () => {
+ expect(service.isValidHour('ab', true)).toBe(false);
+ });
+ });
+ });
+
+ describe('isValidMinutesSeconds', () => {
+ it('should validate single digit minutes or seconds', () => {
+ expect(service.isValidMinutesSeconds('0')).toBe(true);
+ expect(service.isValidMinutesSeconds('5')).toBe(true);
+ expect(service.isValidMinutesSeconds('6')).toBe(false);
+ });
+
+ it('should validate 2-digit minutes or seconds', () => {
+ expect(service.isValidMinutesSeconds('00')).toBe(true);
+ expect(service.isValidMinutesSeconds('59')).toBe(true);
+ expect(service.isValidMinutesSeconds('60')).toBe(false);
+ });
+
+ it('should reject non-numeric minutes or seconds', () => {
+ expect(service.isValidMinutesSeconds('ab')).toBe(false);
+ });
+
+ it('should reject minutes or seconds longer than 2 digits', () => {
+ expect(service.isValidMinutesSeconds('123')).toBe(false);
+ });
+ });
+
+ describe('isValidYear', () => {
+ it('should accept empty year', () => {
+ expect(service.isValidYear('')).toBe(true);
+ });
+
+ it('should accept a 4-digit year', () => {
+ expect(service.isValidYear('1985')).toBe(true);
+ });
+
+ it('should accept partial year input (progressive)', () => {
+ expect(service.isValidYear('1')).toBe(true);
+ expect(service.isValidYear('19')).toBe(true);
+ expect(service.isValidYear('198')).toBe(true);
+ });
+
+ it('should accept ISO 8601 year padded with leading zeros', () => {
+ expect(service.isValidYear('0985')).toBe(true);
+ });
+
+ it('should accept leading zero(s) as progressive input', () => {
+ expect(service.isValidYear('0')).toBe(true);
+ expect(service.isValidYear('00')).toBe(true);
+ expect(service.isValidYear('000')).toBe(true);
+ expect(service.isValidYear('0000')).toBe(true);
+ });
+
+ it('should reject non-numeric year', () => {
+ expect(service.isValidYear('abcd')).toBe(false);
+ expect(service.isValidYear('19ab')).toBe(false);
+ });
+
+ it('should reject year longer than 4 digits', () => {
+ expect(service.isValidYear('12345')).toBe(false);
+ });
+ });
+
+ describe('isValidMonth', () => {
+ it('should accept empty month', () => {
+ expect(service.isValidMonth('')).toBe(true);
+ });
+
+ it('should accept a valid 2-digit month', () => {
+ expect(service.isValidMonth('01')).toBe(true);
+ expect(service.isValidMonth('12')).toBe(true);
+ });
+
+ it('should accept 0 or 1 as a progressive single digit', () => {
+ expect(service.isValidMonth('0')).toBe(true);
+ expect(service.isValidMonth('1')).toBe(true);
+ });
+
+ it('should reject single digit greater than 1', () => {
+ expect(service.isValidMonth('2')).toBe(false);
+ expect(service.isValidMonth('9')).toBe(false);
+ });
+
+ it('should reject month 00', () => {
+ expect(service.isValidMonth('00')).toBe(false);
+ });
+
+ it('should reject month greater than 12', () => {
+ expect(service.isValidMonth('13')).toBe(false);
+ });
+
+ it('should reject non-numeric month', () => {
+ expect(service.isValidMonth('ab')).toBe(false);
+ });
+
+ it('should reject month longer than 2 digits', () => {
+ expect(service.isValidMonth('123')).toBe(false);
+ });
+ });
+
+ describe('isValidDay', () => {
+ it('should accept empty day', () => {
+ expect(service.isValidDay('', '1985', '05')).toBe(true);
+ });
+
+ it('should accept a valid 2-digit day for the month', () => {
+ expect(service.isValidDay('20', '1985', '05')).toBe(true);
+ });
+
+ it('should accept Feb 29 in a leap year', () => {
+ expect(service.isValidDay('29', '2024', '02')).toBe(true);
+ });
+
+ it('should reject Feb 29 in a non-leap year', () => {
+ expect(service.isValidDay('29', '1985', '02')).toBe(false);
+ });
+
+ it('should reject Feb 30', () => {
+ expect(service.isValidDay('30', '1985', '02')).toBe(false);
+ });
+
+ it('should reject day 31 in a 30-day month', () => {
+ expect(service.isValidDay('31', '1985', '04')).toBe(false);
+ });
+
+ it('should reject day 00', () => {
+ expect(service.isValidDay('00', '1985', '05')).toBe(false);
+ });
+
+ it('should reject day greater than 31', () => {
+ expect(service.isValidDay('32', '1985', '01')).toBe(false);
+ });
+
+ it('should reject non-numeric day', () => {
+ expect(service.isValidDay('ab', '1985', '05')).toBe(false);
+ });
+
+ it('should accept single digit as progressive day input', () => {
+ expect(service.isValidDay('3', '1985', '05')).toBe(true);
+ });
+
+ it('should fall back to leap year 2000 when year is missing', () => {
+ // 2000 is a leap year so Feb 29 is allowed
+ expect(service.isValidDay('29', '', '02')).toBe(true);
+ });
+
+ it('should fall back to month 01 (31 days) when month is missing', () => {
+ expect(service.isValidDay('31', '1985', '')).toBe(true);
});
});
@@ -728,14 +1053,6 @@ describe('EdtfService', () => {
expect(result).toBe(edtfString);
});
- it('should roundtrip partial year', () => {
- const edtfString = '19XX';
- const model = service.toDateTimeModel(edtfString);
- const result = service.toEdtfDate(model);
-
- expect(result).toBe(edtfString);
- });
-
it('should roundtrip approximate qualifier', () => {
const edtfString = '1985-05~';
const model = service.toDateTimeModel(edtfString);
@@ -767,5 +1084,69 @@ describe('EdtfService', () => {
expect(result).toBe(edtfString);
});
+
+ it('should roundtrip a full date-time with a timezone offset unchanged', () => {
+ const edtfString = '1985-05-20T23:23:23+05:30';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
+
+ it('should stamp the local offset on a date-time without timezone marker', () => {
+ const model = service.toDateTimeModel('1985-05-20T23:23:23');
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(`1985-05-20T23:23:23${localTimezoneOffset()}`);
+ });
+
+ it('should replace a fabricated Z marker with the local offset', () => {
+ // The folder/record VO layer rewrites offset-less values to
+ // '….000Z', so Z is treated as "no offset" rather than real UTC.
+ const model = service.toDateTimeModel('1985-05-20T23:23:23.000Z');
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(`1985-05-20T23:23:23${localTimezoneOffset()}`);
+ });
+
+ it('should roundtrip partial year (198X)', () => {
+ const edtfString = '198X';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
+
+ it('should roundtrip month-only with unknown year (XXXX-05)', () => {
+ const edtfString = 'XXXX-05';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
+
+ it('should roundtrip known year and day with unknown month (1985-XX-20)', () => {
+ const edtfString = '1985-XX-20';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
+
+ it('should roundtrip partial day (1985-05-2X)', () => {
+ const edtfString = '1985-05-2X';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
+
+ it('should roundtrip partial year with full month and day (198X-05-20)', () => {
+ const edtfString = '198X-05-20';
+ const model = service.toDateTimeModel(edtfString);
+ const result = service.toEdtfDate(model);
+
+ expect(result).toBe(edtfString);
+ });
});
});
diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts
index f6d68d8df..5af5b3b68 100644
--- a/src/app/shared/services/edtf-service/edtf.service.ts
+++ b/src/app/shared/services/edtf-service/edtf.service.ts
@@ -1,5 +1,20 @@
import { Injectable } from '@angular/core';
-import edtf, { Date, Interval } from 'edtf';
+import edtf, { Date as EdtfDate, Interval as EdtfInterval } from 'edtf';
+import { getHours, getMinutes, getSeconds, isValid, parse } from 'date-fns';
+
+export enum DateQualifier {
+ Approximate = 'approximate',
+ Uncertain = 'uncertain',
+ Unknown = 'unknown',
+}
+
+export type TimeFormat = 'am' | 'pm' | 'h24';
+
+export const TIME_FORMAT_LABEL: Record = {
+ am: 'AM',
+ pm: 'PM',
+ h24: '24H',
+};
export enum EdtfPrecision {
Time = 0,
@@ -8,7 +23,16 @@ export enum EdtfPrecision {
Day = 3,
}
-export interface DateQualifier {
+export const UNKNOWN_VALUE = 'XXXX-XX-XX';
+
+export const DEFAULT_TIME: TimeModel = {
+ hours: '',
+ minutes: '',
+ seconds: '',
+ format: 'am',
+};
+
+export interface DateQualifierFlags {
approximate: boolean;
uncertain: boolean;
unknown: boolean;
@@ -24,14 +48,12 @@ export interface TimeModel {
hours?: string;
minutes?: string;
seconds?: string;
- am?: boolean;
- pm?: boolean;
+ format: TimeFormat;
timezoneOffset?: string;
- timezoneName?: string;
}
export interface DateTimeModel {
- qualifiers?: DateQualifier;
+ qualifiers?: DateQualifierFlags;
date: DateModel;
time: TimeModel;
endDate?: DateModel;
@@ -43,172 +65,178 @@ export interface DateTimeModel {
})
export class EdtfService {
toDateTimeModel(edtfString: string): DateTimeModel | null {
- const { timezoneOffset, timezoneName } = this.extractTimezone(edtfString);
- const edtfObject = edtf(edtfString);
-
- if (edtfObject instanceof Interval) {
- return this.intervalToDateTimeModel(
- edtfObject,
- timezoneOffset,
- timezoneName,
- );
- }
- if (!(edtfObject instanceof Date)) {
- return null;
+ try {
+ if (!edtfString) {
+ return null;
+ }
+
+ if (/^X{4}-X{2}-X{2}$/i.test(edtfString)) {
+ return {
+ qualifiers: { approximate: false, uncertain: false, unknown: true },
+ date: { year: '', month: '', day: '' },
+ time: { ...DEFAULT_TIME },
+ };
+ }
+
+ if (edtfString.includes('/')) {
+ return this.parseInterval(edtfString);
+ }
+
+ const normalizedString = this.normalizeForParsing(edtfString);
+ const edtfObject = edtf(normalizedString);
+
+ if (!(edtfObject instanceof EdtfDate)) {
+ return null;
+ }
+
+ return this.extDateToDateTimeModel(edtfObject, edtfString);
+ } catch (error) {
+ throw new Error(this.toHumanReadableError(error));
}
-
- return this.extDateToDateTimeModel(
- edtfObject,
- timezoneOffset,
- timezoneName,
- );
}
- toEdtfDate(model: DateTimeModel): string {
- const { date, time, qualifiers, endDate, endTime } = model;
-
- const isOpenStart = this.isEmptyDateTime(date, time);
- const isOpenEnd = endDate && this.isEmptyDateTime(endDate, endTime);
-
- const startPart = isOpenStart
- ? '..'
- : this.normalizeEdtfString(date, time, qualifiers);
+ private parseInterval(edtfString: string): DateTimeModel | null {
+ const [startPart, endPart] = edtfString.split('/');
- const endPart = isOpenEnd
- ? '..'
- : endDate
- ? this.normalizeEdtfString(endDate, endTime)
- : null;
+ const normalizedStart =
+ startPart === '..' ? '..' : this.normalizeForParsing(startPart);
+ const normalizedEnd =
+ endPart === '..' ? '..' : this.normalizeForParsing(endPart);
- return endPart ? `${startPart}/${endPart}` : startPart;
- }
-
- isValidTime(time: TimeModel): boolean {
- if (!time?.hours) {
- return true;
- }
+ try {
+ const edtfObject = edtf(`${normalizedStart}/${normalizedEnd}`);
- const hours = Number(time.hours);
- if (isNaN(hours) || hours < 1 || hours > 12) {
- return false;
- }
+ if (!(edtfObject instanceof EdtfInterval)) {
+ return null;
+ }
- try {
- const timeStr = this.buildTimeString(time);
- edtf(`2000-01-01${timeStr}Z`);
- return true;
+ return this.intervalToDateTimeModel(edtfObject, startPart, endPart);
} catch {
- return false;
+ return null;
}
}
- isValidDate(date: DateModel): boolean {
- if (!date.year) {
- return false;
- }
+ private normalizeForParsing(edtfString: string): string {
+ // Replace any timezone offset (or absent designator) with Z so the edtf
+ // library treats the wall-clock digits as UTC — otherwise it converts the
+ // value to UTC, which can shift the date parts we extract for display.
+ // The wall-clock time itself is read from the raw string (extractRawTime),
+ // and the original offset is preserved separately in the model.
+ return edtfString.replace(
+ /T(\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)(?:Z|[+-]\d{2}:\d{2})?$/,
+ 'T$1Z',
+ );
+ }
+ toEdtfDate(model: DateTimeModel): string {
try {
- const dateStr = this.buildDateString(date);
- edtf(dateStr);
- return true;
- } catch {
- return false;
+ const { date, time, qualifiers, endDate, endTime } = model;
+
+ if (qualifiers?.unknown) {
+ return UNKNOWN_VALUE;
+ }
+
+ const isStartEmpty = this.isEmptyDateTime(date, time);
+ const isOpenEnd = endDate && this.isEmptyDateTime(endDate, endTime);
+ const hasEndDate = endDate && !this.isEmptyDateTime(endDate, endTime);
+
+ if (isStartEmpty && !hasEndDate) {
+ return '';
+ }
+
+ const startPart = isStartEmpty
+ ? '..'
+ : this.normalizeEdtfString(date, time, qualifiers);
+
+ const endPart = isOpenEnd
+ ? '..'
+ : hasEndDate
+ ? this.normalizeEdtfString(endDate, endTime, qualifiers)
+ : null;
+ const stringDate = endPart ? `${startPart}/${endPart}` : startPart;
+ edtf(stringDate);
+ return stringDate;
+ } catch (error) {
+ throw new Error(this.toHumanReadableError(error));
}
}
- private buildRawEdtfString(
+ private normalizeEdtfString(
date: DateModel,
time: TimeModel,
- qualifiers?: DateQualifier,
+ qualifiers?: DateQualifierFlags,
): string {
const dateStr = this.buildDateString(date);
- const timeStr = this.buildTimeString(time);
- const utcSuffix = timeStr ? 'Z' : '';
+ const edtfObject = edtf(dateStr);
+ // Strip any time/timezone the library may append (e.g. T00:00:00.000Z)
+ let result = edtfObject.toEDTF().replace(/T.*$/, '');
+
+ const hasCompleteDate = !!(date.year && date.month && date.day);
+ const timeStr = hasCompleteDate ? this.buildTimeString(time) : '';
+ if (timeStr) {
+ result = `${result}${timeStr}`;
+ }
+
+ // Add qualifiers after the complete date-time string
if (qualifiers?.approximate && qualifiers?.uncertain) {
- return `${dateStr}%${timeStr}${utcSuffix}`;
+ result += '%';
} else if (qualifiers?.approximate) {
- return `${dateStr}~${timeStr}${utcSuffix}`;
+ result += '~';
} else if (qualifiers?.uncertain) {
- return `${dateStr}?${timeStr}${utcSuffix}`;
+ result += '?';
}
- return `${dateStr}${timeStr}${utcSuffix}`;
+ return result;
}
- private normalizeEdtfString(
- date: DateModel,
- time: TimeModel,
- qualifiers?: DateQualifier,
- ): string {
- const rawString = this.buildRawEdtfString(date, time, qualifiers);
- const timeStr = this.buildTimeString(time);
- const timezone = time?.timezoneOffset || '';
-
- const edtfObject = edtf(rawString);
- let edtfString = edtfObject.toEDTF();
-
- if (timeStr) {
- edtfString = edtfString.replace(/\.000Z$/g, '').replace(/Z$/g, '');
- }
+ private buildDateString(date: DateModel): string {
+ const hasYear = !!date.year;
+ const hasMonth = !!date.month;
+ const hasDay = !!date.day;
- return timezone ? `${edtfString}${timezone}` : edtfString;
- }
+ if (!hasYear && !hasMonth && !hasDay) return '';
- private buildDateString(date: DateModel): string {
- const year = date.year.padEnd(4, 'X');
+ const year = this.padWithX(date.year, 4);
+ if (!hasMonth && !hasDay) return year;
- if (!date.month) {
- return year;
- }
+ const month = hasMonth ? this.padWithX(date.month, 2) : 'XX';
+ if (!hasDay) return `${year}-${month}`;
- if (!date.day) {
- return `${year}-${date.month}`;
- }
+ const day = this.padWithX(date.day, 2);
+ return `${year}-${month}-${day}`;
+ }
- return `${year}-${date.month}-${date.day}`;
+ private padWithX(value: string, width: number): string {
+ const v = value ?? '';
+ return v.length >= width ? v : v + 'X'.repeat(width - v.length);
}
private buildTimeString(time: TimeModel): string {
- if (!time?.hours) {
- return '';
- }
+ if (!time?.hours) return '';
- let hours24 = Number(time.hours);
- if (time.pm && hours24 !== 12) {
- hours24 += 12;
- } else if (time.am && hours24 === 12) {
- hours24 = 0;
+ const converted = this.parseTimeAs24Hour(time);
+ if (!converted) {
+ throw new Error('Invalid time');
}
- const minutes = time.minutes ?? '00';
- const seconds = time.seconds ?? '00';
- return `T${String(hours24).padStart(2, '0')}:${minutes}:${seconds}`;
+ const timezoneOffset = time.timezoneOffset ?? this.localTimezoneOffset();
+ const pad = (n: number): string => String(n).padStart(2, '0');
+ return `T${pad(converted.hour)}:${pad(converted.minute)}:${pad(converted.second)}${timezoneOffset}`;
}
- private extractTimezone(edtfString: string): {
- timezoneOffset: string;
- timezoneName: string;
- } {
- const timezoneMatch = edtfString.match(/T[\d:]+([+-]\d{2}:\d{2}|Z)$/);
-
- if (!timezoneMatch) {
- return { timezoneOffset: '', timezoneName: '' };
- }
-
- const offset = timezoneMatch[1];
-
- if (offset === 'Z') {
- return { timezoneOffset: '+00:00', timezoneName: 'UTC' };
- }
-
- return { timezoneOffset: offset, timezoneName: '' };
+ private localTimezoneOffset(): string {
+ const offsetMinutes = -new Date().getTimezoneOffset();
+ const sign = offsetMinutes < 0 ? '-' : '+';
+ const absoluteMinutes = Math.abs(offsetMinutes);
+ const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0');
+ const minutes = String(absoluteMinutes % 60).padStart(2, '0');
+ return `${sign}${hours}:${minutes}`;
}
private extDateToDateTimeModel(
- extDate: Date,
- timezoneOffset: string,
- timezoneName: string,
+ extDate: EdtfDate,
+ rawEdtfString: string,
): DateTimeModel {
const edtfStr = extDate.toEDTF();
const precision = extDate.precision;
@@ -216,8 +244,10 @@ export class EdtfService {
const yearStr = edtfStr.match(/^-?\d*X*/)?.[0] || String(extDate.year);
const year = yearStr.replace(/X/g, '');
- const hasMonth = precision >= EdtfPrecision.Month;
- const hasDay = precision >= EdtfPrecision.Day;
+ const hasMonth =
+ precision === EdtfPrecision.Time || precision >= EdtfPrecision.Month;
+ const hasDay =
+ precision === EdtfPrecision.Time || precision >= EdtfPrecision.Day;
const monthPart = hasMonth
? edtfStr.split('-')[1]?.replace(/[^0-9X]/g, '')
@@ -229,11 +259,19 @@ export class EdtfService {
.substring(0, 2)
: undefined;
- const month = monthPart === 'XX' ? undefined : monthPart;
- const day = dayPart === 'XX' ? undefined : dayPart;
+ const month = (monthPart ?? '').replace(/X+$/i, '');
+ const day = (dayPart ?? '').replace(/X+$/i, '');
const hasTime = precision === EdtfPrecision.Time || edtfStr.includes('T');
- const hours24 = extDate.hours;
+ // Read the wall-clock time from the original input string instead of
+ // extDate.hours/minutes/seconds — the edtf library treats unmarked
+ // times as local and shifts them to UTC, which corrupts the value
+ // we want to display unchanged (see node_modules/edtf/src/util.js
+ // `datetime` postprocess).
+ const rawTime = hasTime ? this.extractRawTime(rawEdtfString) : null;
+ const hours24 = rawTime?.hours ?? 0;
+ const minutes = rawTime?.minutes ?? 0;
+ const seconds = rawTime?.seconds ?? 0;
const isPm = hours24 >= 12;
const hours12 = hours24 % 12 || 12;
@@ -249,25 +287,55 @@ export class EdtfService {
day,
},
time: {
- hours: hasTime ? String(hours12).padStart(2, '0') : undefined,
- minutes: hasTime ? String(extDate.minutes).padStart(2, '0') : undefined,
- seconds: hasTime ? String(extDate.seconds).padStart(2, '0') : undefined,
- am: hasTime ? !isPm : undefined,
- pm: hasTime ? isPm : undefined,
- timezoneOffset,
- timezoneName,
+ hours: hasTime ? String(hours12).padStart(2, '0') : '',
+ minutes: hasTime ? String(minutes).padStart(2, '0') : '',
+ seconds: hasTime ? String(seconds).padStart(2, '0') : '',
+ format: hasTime && isPm ? 'pm' : 'am',
+ timezoneOffset: rawTime?.timezoneOffset,
},
};
}
+ private extractRawTime(edtfString: string): {
+ hours: number;
+ minutes: number;
+ seconds: number;
+ timezoneOffset?: string;
+ } | null {
+ const match = edtfString.match(
+ /T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?([+-]\d{2}:\d{2})?/,
+ );
+ if (!match) return null;
+ return {
+ hours: parseInt(match[1], 10),
+ minutes: parseInt(match[2], 10),
+ seconds: match[3] ? parseInt(match[3], 10) : 0,
+ timezoneOffset: match[4],
+ };
+ }
+
private isEmptyDateTime(date: DateModel, time: TimeModel): boolean {
return !date.year && !date.month && !date.day && !time?.hours;
}
+ private toHumanReadableError(error: unknown): string {
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
+
+ if (
+ message.includes('invalid interval') ||
+ message.includes('invalid lower bound') ||
+ message.includes('invalid upper bound')
+ ) {
+ return 'The date range is not valid. Please make sure the start date is before the end date.';
+ }
+
+ return 'The date entered is not valid. Please check the values and try again.';
+ }
+
private intervalToDateTimeModel(
- interval: Interval,
- timezoneOffset: string,
- timezoneName: string,
+ interval: EdtfInterval,
+ startRaw: string,
+ endRaw: string,
): DateTimeModel {
const lower = interval.lower;
const upper = interval.upper;
@@ -275,28 +343,99 @@ export class EdtfService {
const openStart = typeof lower === 'number' || lower === null;
const openEnd = typeof upper === 'number' || upper === null;
- const emptyDateTime = {
- date: { year: '' } as DateModel,
- time: { timezoneOffset, timezoneName } as TimeModel,
- };
-
const model: DateTimeModel = openStart
- ? { ...emptyDateTime }
- : this.extDateToDateTimeModel(lower, timezoneOffset, timezoneName);
+ ? { date: { year: '' } as DateModel, time: { format: 'am' } }
+ : this.extDateToDateTimeModel(lower, startRaw);
if (openEnd) {
- model.endDate = { ...emptyDateTime.date };
- model.endTime = { ...emptyDateTime.time };
+ model.endDate = { year: '' } as DateModel;
+ model.endTime = { format: 'am' };
} else if (upper) {
- const upperModel = this.extDateToDateTimeModel(
- upper,
- timezoneOffset,
- timezoneName,
- );
+ const upperModel = this.extDateToDateTimeModel(upper, endRaw);
model.endDate = upperModel.date;
model.endTime = upperModel.time;
}
return model;
}
+
+ parseTimeAs24Hour(
+ time: TimeModel,
+ ): { hour: number; minute: number; second: number } | null {
+ const minutesInput = time.minutes || '0';
+ const secondsInput = time.seconds || '0';
+
+ if (time.format === 'h24') {
+ const parsed = parse(
+ `${time.hours || '0'}:${minutesInput}:${secondsInput}`,
+ 'H:m:s',
+ new Date(),
+ );
+ if (!isValid(parsed)) return null;
+ return {
+ hour: getHours(parsed),
+ minute: getMinutes(parsed),
+ second: getSeconds(parsed),
+ };
+ }
+
+ // '12' default lets empty hours collapse to midnight via the AM branch below
+ // (12-hour clock has no '0', so parse('0 AM') would be invalid)
+ const hoursInput = time.hours || '12';
+ const meridian = time.format === 'pm' ? 'PM' : 'AM';
+
+ const parsed = parse(
+ `${hoursInput}:${minutesInput}:${secondsInput} ${meridian}`,
+ 'h:m:s a',
+ new Date(),
+ );
+
+ if (!isValid(parsed)) return null;
+
+ return {
+ hour: getHours(parsed),
+ minute: getMinutes(parsed),
+ second: getSeconds(parsed),
+ };
+ }
+
+ isValidHour(value: string, is24Hour = false): boolean {
+ if (is24Hour) {
+ if (value.length === 1) return parseInt(value, 10) <= 2;
+ // 24-hour clock — defer to date-fns parse to verify the full value (00-23)
+ return isValid(parse(`${value}:00`, 'HH:mm', new Date()));
+ }
+ if (value.length === 1) return parseInt(value, 10) <= 1;
+ // 12-hour clock — defer to date-fns parse to verify the full value (01-12)
+ return isValid(parse(`${value}:00 AM`, 'hh:mm a', new Date()));
+ }
+
+ isValidMinutesSeconds(value: string): boolean {
+ if (value.length === 1) return parseInt(value, 10) <= 5;
+ // Defer to date-fns parse to verify the full value (00-59)
+ return isValid(parse(`12:${value}:00 AM`, 'hh:mm:ss a', new Date()));
+ }
+
+ isValidYear(value: string): boolean {
+ if (value === '') return true;
+ return /^\d{1,4}$/.test(value);
+ }
+
+ isValidMonth(value: string): boolean {
+ if (value === '') return true;
+ if (/^\d$/.test(value)) return parseInt(value, 10) <= 1;
+ return isValid(parse(value, 'MM', new Date()));
+ }
+
+ isValidDay(value: string, year: string, month: string): boolean {
+ if (value === '') return true;
+ // Defaults let day be typed before year/month are filled in.
+ // 2000 is a leap year (allows Feb 29); 01 has 31 days (most permissive).
+ const yearStr = year.length === 4 ? year : '2000';
+ const monthStr = month.length === 2 ? month : '01';
+ const dayStr = value.padStart(2, '0');
+ return isValid(
+ parse(`${yearStr}-${monthStr}-${dayStr}`, 'yyyy-MM-dd', new Date()),
+ );
+ }
}
diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss
index 42c05a46e..8d587c4ab 100644
--- a/src/styles/_colors.scss
+++ b/src/styles/_colors.scss
@@ -7,6 +7,14 @@ $PR-orange: #ff9933;
$PR-blue-light: #5261b7;
$PR-blue-lightest: #41496e;
+$PR-blue-25: #f4f6fd;
+$PR-blue-50: #e7e8ed;
+$PR-blue-100: #d0d1db;
+$PR-blue-300: #a1a4b7;
+$PR-blue-400: #898da4;
+$PR-blue-600: #5a5f80;
+$PR-blue-900: #131b4a;
+
$PR-orange-light: #ffc779;
$PR-orange-lightest: #ffead5;
diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss
index 8f73dad50..51d51de94 100644
--- a/src/styles/_mixins.scss
+++ b/src/styles/_mixins.scss
@@ -19,3 +19,89 @@
line-height: 22px;
color: white;
}
+
+// Date/Time Picker Component Mixins
+@mixin usual-text {
+ font-family: 'Usual', sans-serif;
+ font-weight: 400;
+ font-style: normal;
+ font-size: 14px;
+ line-height: 24px;
+ letter-spacing: 0;
+}
+
+@mixin input-container {
+ display: flex;
+ align-items: center;
+ background: $white;
+ border: 1px solid $PR-blue-100;
+ border-radius: 8px;
+ height: 40px;
+ flex: 1;
+}
+
+@mixin input-focus-state {
+ box-shadow: 0px 0px 0px 4px #131b4a08;
+}
+
+@mixin icon-wrapper {
+ background: $PR-blue-25;
+ border-radius: 0 8px 8px 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ gap: 16px;
+ opacity: 1;
+}
+
+@mixin clear-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+
+ i {
+ color: $red;
+ }
+
+ span {
+ font-weight: 500;
+ color: $red;
+ }
+
+ &:hover {
+ opacity: 0.8;
+ }
+}
+
+@mixin panel-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid $PR-blue-100;
+ background: $PR-blue-25;
+ border-radius: 0 0 12px 12px;
+}
+
+@mixin input-segment {
+ border: none;
+ background: transparent;
+ text-align: center;
+ @include usual-text;
+ color: $PR-blue;
+ outline: none;
+
+ &::placeholder {
+ color: $PR-blue-400;
+ }
+
+ &:disabled {
+ color: $PR-blue-400;
+ cursor: not-allowed;
+ }
+}