From a5958f7c127d3b6c17523af1370396c0e8573bbd Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 27 May 2026 19:57:22 +0300 Subject: [PATCH 1/5] Add EDTF datepicker, timepicker, and timezone-dropdown components Introduce three shared components for EDTF date/time editing: datepicker-input, timepicker-input, and timezone-dropdown. Includes related updates to EdtfService and to the shared styles (_colors.scss and _mixins.scss) to support the new components. Issue: PER-10632 --- .../datepicker-input.component.html | 54 ++ .../datepicker-input.component.scss | 123 ++++ .../datepicker-input.component.spec.ts | 221 ++++++ .../datepicker-input.component.ts | 156 ++++ .../timepicker-input.component.html | 58 ++ .../timepicker-input.component.scss | 162 +++++ .../timepicker-input.component.spec.ts | 328 +++++++++ .../timepicker-input.component.ts | 166 +++++ .../timezone-dropdown.component.html | 45 ++ .../timezone-dropdown.component.scss | 107 +++ .../timezone-dropdown.component.spec.ts | 126 ++++ .../timezone-dropdown.component.ts | 118 +++ .../edtf-service/edtf.service.spec.ts | 687 ++++++++++-------- .../services/edtf-service/edtf.service.ts | 377 ++++++---- src/styles/_colors.scss | 8 + src/styles/_mixins.scss | 86 +++ 16 files changed, 2349 insertions(+), 473 deletions(-) create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.html create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.scss create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.ts create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.html create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.scss create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.ts create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts 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..90341844e --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.html @@ -0,0 +1,54 @@ +
+ + / + + / + +
+ + calendar_today + +
+
+ +@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..50e3472e0 --- /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 not emit for incomplete year', () => { + component.updateYear(mockEvent('202')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + 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 allow typing intermediate digits without emitting', () => { + component.updateYear(mockEvent('19')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + 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..55850bbde --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.ts @@ -0,0 +1,156 @@ +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; + } + + if (value.length === 4) { + this.dateChange.emit({ ...this.date, year: value }); + this.monthInput.nativeElement.focus(); + } else if (value.length === 0) { + this.dateChange.emit({ ...this.date, year: value }); + } + } + + 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..44916eed0 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html @@ -0,0 +1,58 @@ +
+ + : + + : + + +
+ + access_time + +
+
+ +@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..f25614e7a --- /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; + 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..4843a0938 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts @@ -0,0 +1,328 @@ +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: '', + am: true, + pm: false, + }; + 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 --- + + 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(); + }); + + // --- 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', + am: true, + pm: false, + }; + fixture.detectChanges(); + component.updateTime(mockEvent(''), 'hours'); + + expect(hostComponent.lastEmittedTime?.hours).toBe(''); + }); + + // --- AM/PM toggle --- + + it('should toggle AM to PM', () => { + hostComponent.time = { + hours: '10', + minutes: '30', + seconds: '', + am: true, + pm: false, + }; + fixture.detectChanges(); + component.toggleAmPm(); + + expect(hostComponent.lastEmittedTime?.pm).toBeTrue(); + }); + + it('should toggle PM to AM', () => { + hostComponent.time = { + hours: '10', + minutes: '30', + seconds: '', + am: false, + pm: true, + }; + fixture.detectChanges(); + component.toggleAmPm(); + + expect(hostComponent.lastEmittedTime?.am).toBeTrue(); + }); + + // --- 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', + am: false, + pm: true, + }; + 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', + am: true, + pm: false, + }; + 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', + am: false, + pm: true, + }; + 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: '', + am: true, + pm: false, + }; + fixture.detectChanges(); + + expect(component.timepickerControl.value).toEqual({ + hour: 0, + minute: 0, + second: 0, + }); + }); + + // --- 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', + am: true, + pm: false, + }); + }); + + 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', + am: false, + pm: true, + }); + }); + + 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?.pm).toBeTrue(); + }); + + 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?.am).toBeTrue(); + }); + + it('should not emit on null ngb-timepicker value', () => { + component.onTimeSelect(null); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); +}); 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..786ab1128 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts @@ -0,0 +1,166 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + 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, + 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 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) { + 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 isPm = ngbTime.hour >= 12; + const displayHour = ngbTime.hour % 12 || 12; + + this.timeChange.emit({ + hours: String(displayHour).padStart(2, '0'), + minutes: String(ngbTime.minute).padStart(2, '0'), + seconds: String(ngbTime.second ?? 0).padStart(2, '0'), + am: !isPm, + pm: isPm, + }); + } + + toggleAmPm(): void { + this.timeChange.emit({ + ...this.time, + am: !!this.time.pm, + pm: !!this.time.am, + }); + } + + 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.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.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..96af7d73d --- /dev/null +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html @@ -0,0 +1,45 @@ +
+
+ {{ 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..136c72ea2 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -13,8 +13,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 +22,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 +34,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 +56,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'); @@ -120,44 +114,23 @@ describe('EdtfService', () => { expect(result.time.pm).toBe(true); }); - 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(); - }); - }); - - describe('timezone', () => { - it('should extract UTC timezone from Z suffix', () => { - const result = service.toDateTimeModel('1985-05-20T14:30:45Z'); - - expect(result.time.timezoneOffset).toBe('+00:00'); - expect(result.time.timezoneName).toBe('UTC'); + expect(result.time.hours).toBe(''); + expect(result.time.minutes).toBe(''); + expect(result.time.seconds).toBe(''); + expect(result.time.am).toBe(true); + expect(result.time.pm).toBe(false); }); - it('should extract positive timezone offset', () => { + it('should parse time with a timezone offset suffix (offset is discarded)', () => { 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'); - - expect(result.time.timezoneOffset).toBe('-04:00'); - expect(result.time.timezoneName).toBe(''); - }); - - it('should have empty timezone when none present', () => { - const result = service.toDateTimeModel('1985-05-20'); - - expect(result.time.timezoneOffset).toBe(''); - expect(result.time.timezoneName).toBe(''); + expect(result.time.hours).toBe('02'); + expect(result.time.minutes).toBe('30'); + expect(result.time.seconds).toBe('45'); + expect(result.time.pm).toBe(true); }); }); @@ -217,36 +190,18 @@ 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 (offsets are discarded)', () => { + 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'); }); }); }); @@ -256,7 +211,7 @@ describe('EdtfService', () => { it('should build year-only EDTF string', () => { const model: DateTimeModel = { date: { year: '1985' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, }; expect(service.toEdtfDate(model)).toBe('1985'); @@ -265,7 +220,7 @@ describe('EdtfService', () => { it('should build year-month EDTF string', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, }; expect(service.toEdtfDate(model)).toBe('1985-05'); @@ -274,33 +229,13 @@ describe('EdtfService', () => { it('should build full date EDTF string', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, }; expect(service.toEdtfDate(model)).toBe('1985-05-20'); }); }); - describe('partial year output', () => { - it('should pad partial year with X', () => { - const model: DateTimeModel = { - date: { year: '19' }, - time: { timezoneOffset: '', timezoneName: '' }, - }; - - expect(service.toEdtfDate(model)).toBe('19XX'); - }); - - it('should pad single digit year with X', () => { - const model: DateTimeModel = { - date: { year: '1' }, - time: { timezoneOffset: '', timezoneName: '' }, - }; - - expect(service.toEdtfDate(model)).toBe('1XXX'); - }); - }); - describe('time building', () => { it('should build date with PM time', () => { const model: DateTimeModel = { @@ -311,8 +246,6 @@ describe('EdtfService', () => { seconds: '45', am: false, pm: true, - timezoneOffset: '', - timezoneName: '', }, }; @@ -330,8 +263,6 @@ describe('EdtfService', () => { seconds: '00', am: true, pm: false, - timezoneOffset: '', - timezoneName: '', }, }; @@ -349,8 +280,6 @@ describe('EdtfService', () => { seconds: '00', am: true, pm: false, - timezoneOffset: '', - timezoneName: '', }, }; @@ -368,8 +297,6 @@ describe('EdtfService', () => { seconds: '00', am: false, pm: true, - timezoneOffset: '', - timezoneName: '', }, }; @@ -381,17 +308,15 @@ describe('EdtfService', () => { it('should omit time when hours not provided', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, }; const result = service.toEdtfDate(model); expect(result).not.toContain('T'); }); - }); - describe('timezone output', () => { - it('should append timezone offset', () => { + it('should not append any timezone suffix to the serialized EDTF string', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, time: { @@ -400,44 +325,12 @@ describe('EdtfService', () => { seconds: '45', am: false, pm: true, - timezoneOffset: '+05:30', - timezoneName: '', }, }; const result = service.toEdtfDate(model); - expect(result).toContain('+05:30'); - }); - - it('should append negative timezone offset', () => { - 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: '', - }, - }; - - 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'); }); }); @@ -445,7 +338,7 @@ describe('EdtfService', () => { it('should add approximate qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, qualifiers: { approximate: true, uncertain: false, unknown: false }, }; @@ -455,7 +348,7 @@ describe('EdtfService', () => { it('should add uncertain qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, qualifiers: { approximate: false, uncertain: true, unknown: false }, }; @@ -465,7 +358,7 @@ describe('EdtfService', () => { it('should add combined qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, qualifiers: { approximate: true, uncertain: true, unknown: false }, }; @@ -475,21 +368,31 @@ describe('EdtfService', () => { it('should not add qualifier when none set', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, 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: {}, + 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: {}, endDate: { year: '1990', month: '06' }, - endTime: { timezoneOffset: '', timezoneName: '' }, + endTime: {}, }; expect(service.toEdtfDate(model)).toBe('1985-05/1990-06'); @@ -498,9 +401,9 @@ describe('EdtfService', () => { it('should build a full date range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, endDate: { year: '1990', month: '06', day: '15' }, - endTime: { timezoneOffset: '', timezoneName: '' }, + endTime: {}, }; expect(service.toEdtfDate(model)).toBe('1985-05-20/1990-06-15'); @@ -509,197 +412,357 @@ describe('EdtfService', () => { it('should build a year-only range', () => { const model: DateTimeModel = { date: { year: '1985' }, - time: { timezoneOffset: '', timezoneName: '' }, + time: {}, endDate: { year: '1990' }, - endTime: { timezoneOffset: '', timezoneName: '' }, + endTime: {}, }; 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: {}, + endDate: { year: '1990', month: '06' }, + endTime: {}, + qualifiers: { approximate: true, uncertain: false, unknown: false }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05~/1990-06~'); + }); + + it('should apply uncertain qualifier to both dates in a range', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05' }, + time: {}, + endDate: { year: '1990', month: '06' }, + endTime: {}, + 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: {}, + endDate: { year: '1990', month: '06' }, + endTime: {}, + qualifiers: { approximate: true, uncertain: true, 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 qualifier only to the start when the end is open', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05' }, + time: {}, + endDate: { year: '', month: '', day: '' }, + endTime: {}, + 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: {}, + endDate: { year: '1990', month: '06' }, + endTime: {}, + 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 build fully open interval', () => { + it('should return null for empty string', () => { + const result = service.toDateTimeModel(''); + + expect(result).toBeNull(); + }); + }); + + 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: {}, }; - 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', + pm: true, + }); + + expect(result).toEqual({ hour: 14, minute: 30, second: 45 }); + }); + + it('should convert AM time to 24-hour format', () => { + const result = service.parseTimeAs24Hour({ + hours: '09', + minutes: '15', + seconds: '00', + am: true, + pm: false, + }); + + expect(result).toEqual({ hour: 9, minute: 15, second: 0 }); + }); + + it('should convert 12 PM to 12', () => { + const result = service.parseTimeAs24Hour({ + hours: '12', + minutes: '00', + seconds: '00', + pm: true, + }); + + expect(result).toEqual({ hour: 12, minute: 0, second: 0 }); + }); + + it('should convert 12 AM to 0', () => { + const result = service.parseTimeAs24Hour({ + hours: '12', + minutes: '00', + seconds: '00', + am: true, + pm: false, + }); + + expect(result).toEqual({ hour: 0, minute: 0, second: 0 }); + }); + + it('should default missing seconds to 0', () => { + const result = service.parseTimeAs24Hour({ + hours: '05', + minutes: '30', + pm: false, + }); + + expect(result.second).toBe(0); + }); + + it('should default missing hours and minutes to 0', () => { + const result = service.parseTimeAs24Hour({}); + + 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', + am: true, + pm: false, + }); + + expect(result).toEqual({ hour: 7, minute: 5, second: 3 }); }); + }); - it('should return true for a valid year-month', () => { - expect(service.isValidDate({ year: '1985', month: '05', day: '' })).toBe( - true, - ); + 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 a valid year only', () => { - expect(service.isValidDate({ year: '1985', month: '', day: '' })).toBe( - true, - ); + 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 true for a partial year', () => { - expect(service.isValidDate({ year: '19', month: '', day: '' })).toBe( - true, - ); + it('should return false for hour 13', () => { + expect(service.isValidHour('13')).toBe(false); }); - it('should return false for an invalid month', () => { - expect(service.isValidDate({ year: '1985', month: '13', day: '' })).toBe( - false, - ); + it('should return false for hour 00', () => { + expect(service.isValidHour('00')).toBe(false); }); - it('should return false for an invalid day', () => { - expect( - service.isValidDate({ year: '1985', month: '05', day: '32' }), - ).toBe(false); + it('should return false for non-numeric input', () => { + expect(service.isValidHour('ab')).toBe(false); }); - it('should return false for an empty year', () => { - expect(service.isValidDate({ year: '', month: '', day: '' })).toBe(false); + it('should return false for input longer than 2 digits', () => { + expect(service.isValidHour('123')).toBe(false); }); }); - 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('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 +791,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); diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index f6d68d8df..753c404df 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -1,5 +1,17 @@ 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 enum Meridian { + AM = 'AM', + PM = 'PM', +} export enum EdtfPrecision { Time = 0, @@ -8,7 +20,17 @@ export enum EdtfPrecision { Day = 3, } -export interface DateQualifier { +export const UNKNOWN_VALUE = 'XXXX-XX-XX'; + +export const DEFAULT_TIME: TimeModel = { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, +}; + +export interface DateQualifierFlags { approximate: boolean; uncertain: boolean; unknown: boolean; @@ -26,12 +48,10 @@ export interface TimeModel { seconds?: string; am?: boolean; pm?: boolean; - timezoneOffset?: string; - timezoneName?: string; } export interface DateTimeModel { - qualifiers?: DateQualifier; + qualifiers?: DateQualifierFlags; date: DateModel; time: TimeModel; endDate?: DateModel; @@ -43,181 +63,165 @@ 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); + } 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); - - const endPart = isOpenEnd - ? '..' - : endDate - ? this.normalizeEdtfString(endDate, endTime) - : null; + private parseInterval(edtfString: string): DateTimeModel | null { + const [startPart, endPart] = edtfString.split('/'); - return endPart ? `${startPart}/${endPart}` : startPart; - } + const normalizedStart = + startPart === '..' ? '..' : this.normalizeForParsing(startPart); + const normalizedEnd = + endPart === '..' ? '..' : this.normalizeForParsing(endPart); - 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); } catch { - return false; + return null; } } - isValidDate(date: DateModel): boolean { - if (!date.year) { - return false; - } + private normalizeForParsing(edtfString: string): string { + // Replace +hh:mm / -hh:mm timezone offset with Z so the edtf library + // can parse it — the library requires a UTC designator and throws on offsets. + return edtfString.replace(/T([\d:]+)[+-]\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}`; - } - - 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, ''); - } - - return timezone ? `${edtfString}${timezone}` : edtfString; + return result; } private buildDateString(date: DateModel): string { - const year = date.year.padEnd(4, 'X'); + const year = date.year.padStart(4, '0'); if (!date.month) { return year; } + const month = date.month.padStart(2, '0'); + if (!date.day) { - return `${year}-${date.month}`; + return `${year}-${month}`; } - return `${year}-${date.month}-${date.day}`; + const day = date.day.padStart(2, '0'); + return `${year}-${month}-${day}`; } 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 pad = (n: number): string => String(n).padStart(2, '0'); + return `T${pad(converted.hour)}:${pad(converted.minute)}:${pad(converted.second)}`; } - 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 extDateToDateTimeModel( - extDate: Date, - timezoneOffset: string, - timezoneName: string, - ): DateTimeModel { + private extDateToDateTimeModel(extDate: EdtfDate): DateTimeModel { const edtfStr = extDate.toEDTF(); const precision = extDate.precision; 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,8 +233,8 @@ export class EdtfService { .substring(0, 2) : undefined; - const month = monthPart === 'XX' ? undefined : monthPart; - const day = dayPart === 'XX' ? undefined : dayPart; + const month = monthPart === 'XX' ? '' : (monthPart ?? ''); + const day = dayPart === 'XX' ? '' : (dayPart ?? ''); const hasTime = precision === EdtfPrecision.Time || edtfStr.includes('T'); const hours24 = extDate.hours; @@ -249,13 +253,11 @@ 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(extDate.minutes).padStart(2, '0') : '', + seconds: hasTime ? String(extDate.seconds).padStart(2, '0') : '', + am: hasTime ? !isPm : true, + pm: hasTime ? isPm : false, }, }; } @@ -264,39 +266,100 @@ export class EdtfService { return !date.year && !date.month && !date.day && !time?.hours; } - private intervalToDateTimeModel( - interval: Interval, - timezoneOffset: string, - timezoneName: string, - ): DateTimeModel { + 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: EdtfInterval): DateTimeModel { const lower = interval.lower; const upper = interval.upper; 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: {} as TimeModel } + : this.extDateToDateTimeModel(lower); if (openEnd) { - model.endDate = { ...emptyDateTime.date }; - model.endTime = { ...emptyDateTime.time }; + model.endDate = { year: '' } as DateModel; + model.endTime = {} as TimeModel; } else if (upper) { - const upperModel = this.extDateToDateTimeModel( - upper, - timezoneOffset, - timezoneName, - ); + const upperModel = this.extDateToDateTimeModel(upper); model.endDate = upperModel.date; model.endTime = upperModel.time; } return model; } + + parseTimeAs24Hour( + time: TimeModel, + ): { hour: number; minute: number; second: number } | null { + // '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 minutesInput = time.minutes || '0'; + const secondsInput = time.seconds || '0'; + const meridian = time.pm === true ? '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): boolean { + 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; + } +} From 7da742e800002598179f393d88f4223f473a6cd8 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 3 Jun 2026 17:22:16 +0300 Subject: [PATCH 2/5] Add the 24h format for the time picker The time picker only supported Meridian times, but now we can also have a 24h valid time input. In order to enable the 24h time, click the AM/PM/24H format cycle button. Issue: PER-10632 --- .../timepicker-input.component.html | 6 +- .../timepicker-input.component.scss | 2 +- .../timepicker-input.component.spec.ts | 225 +++++++++++++++--- .../timepicker-input.component.ts | 45 +++- .../edtf-service/edtf.service.spec.ts | 210 +++++++++++----- .../services/edtf-service/edtf.service.ts | 97 ++++++-- 6 files changed, 457 insertions(+), 128 deletions(-) diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.html b/src/app/shared/components/timepicker-input/timepicker-input.component.html index 44916eed0..128687e70 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.html +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html @@ -33,8 +33,8 @@ placeholder="ss" maxlength="2" /> -
diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.scss b/src/app/shared/components/timepicker-input/timepicker-input.component.scss index f25614e7a..fc34d247b 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.scss +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.scss @@ -49,7 +49,7 @@ cursor: pointer; margin-left: auto; margin-right: 4px; - width: 40px; + min-width: 40px; height: 24px; gap: 4px; opacity: 1; 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 index 4843a0938..6d6166918 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts @@ -17,8 +17,7 @@ class TestHostComponent { hours: '', minutes: '', seconds: '', - am: true, - pm: false, + format: 'am', }; disabled = false; lastEmittedTime: TimeModel | null = null; @@ -81,7 +80,7 @@ describe('TimepickerInputComponent', () => { expect(component.showTimepicker()).toBeFalse(); }); - // --- Hour validation --- + // --- Hour validation (12-hour) --- it('should accept valid hour', () => { component.updateTime(mockEvent('10'), 'hours'); @@ -113,6 +112,77 @@ describe('TimepickerInputComponent', () => { 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', () => { @@ -160,8 +230,7 @@ describe('TimepickerInputComponent', () => { hours: '10', minutes: '30', seconds: '00', - am: true, - pm: false, + format: 'am', }; fixture.detectChanges(); component.updateTime(mockEvent(''), 'hours'); @@ -169,34 +238,102 @@ describe('TimepickerInputComponent', () => { expect(hostComponent.lastEmittedTime?.hours).toBe(''); }); - // --- AM/PM toggle --- + // --- 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 toggle AM to PM', () => { + it('should cycle PM -> h24', () => { hostComponent.time = { hours: '10', minutes: '30', seconds: '', - am: true, - pm: false, + format: 'pm', }; fixture.detectChanges(); - component.toggleAmPm(); + component.cycleFormat(); - expect(hostComponent.lastEmittedTime?.pm).toBeTrue(); + expect(hostComponent.lastEmittedTime?.format).toBe('h24'); }); - it('should toggle PM to AM', () => { + 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: '', - am: false, - pm: true, + 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(); - component.toggleAmPm(); - expect(hostComponent.lastEmittedTime?.am).toBeTrue(); + 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 --- @@ -217,8 +354,7 @@ describe('TimepickerInputComponent', () => { hours: '02', minutes: '30', seconds: '15', - am: false, - pm: true, + format: 'pm', }; fixture.detectChanges(); @@ -234,8 +370,7 @@ describe('TimepickerInputComponent', () => { hours: '12', minutes: '00', seconds: '00', - am: true, - pm: false, + format: 'am', }; fixture.detectChanges(); @@ -251,8 +386,7 @@ describe('TimepickerInputComponent', () => { hours: '12', minutes: '00', seconds: '00', - am: false, - pm: true, + format: 'pm', }; fixture.detectChanges(); @@ -268,8 +402,7 @@ describe('TimepickerInputComponent', () => { hours: '', minutes: '', seconds: '', - am: true, - pm: false, + format: 'am', }; fixture.detectChanges(); @@ -280,6 +413,22 @@ describe('TimepickerInputComponent', () => { }); }); + 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', () => { @@ -289,8 +438,7 @@ describe('TimepickerInputComponent', () => { hours: '09', minutes: '15', seconds: '30', - am: true, - pm: false, + format: 'am', }); }); @@ -301,8 +449,7 @@ describe('TimepickerInputComponent', () => { hours: '02', minutes: '45', seconds: '00', - am: false, - pm: true, + format: 'pm', }); }); @@ -310,14 +457,32 @@ describe('TimepickerInputComponent', () => { component.onTimeSelect({ hour: 12, minute: 0, second: 0 }); expect(hostComponent.lastEmittedTime?.hours).toBe('12'); - expect(hostComponent.lastEmittedTime?.pm).toBeTrue(); + 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?.am).toBeTrue(); + 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', () => { diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.ts index 786ab1128..fbe1809dd 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts @@ -4,6 +4,7 @@ import { Output, EventEmitter, signal, + computed, HostListener, OnInit, OnChanges, @@ -18,6 +19,8 @@ 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'; @@ -42,6 +45,12 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { 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( @@ -57,6 +66,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { 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)) { @@ -85,24 +95,35 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { 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({ + 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({ hours: String(displayHour).padStart(2, '0'), - minutes: String(ngbTime.minute).padStart(2, '0'), - seconds: String(ngbTime.second ?? 0).padStart(2, '0'), - am: !isPm, - pm: isPm, + minutes, + seconds, + format: isPm ? 'pm' : 'am', }); } - toggleAmPm(): void { - this.timeChange.emit({ - ...this.time, - am: !!this.time.pm, - pm: !!this.time.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 { @@ -136,7 +157,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (value !== '') { const isValid = timePropKey === 'hours' - ? this.edtfService.isValidHour(value) + ? this.edtfService.isValidHour(value, this.is24Hour()) : this.edtfService.isValidMinutesSeconds(value); if (!isValid) { input.value = this.time[timePropKey] ?? ''; @@ -149,7 +170,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (nextField && value.length === 2) { const isComplete = timePropKey === 'hours' - ? this.edtfService.isValidHour(value) + ? this.edtfService.isValidHour(value, this.is24Hour()) : this.edtfService.isValidMinutesSeconds(value); if (isComplete) nextField.focus(); } 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 136c72ea2..205bb38dc 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -86,32 +86,28 @@ 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 empty time fields when no time present', () => { @@ -120,8 +116,7 @@ describe('EdtfService', () => { expect(result.time.hours).toBe(''); expect(result.time.minutes).toBe(''); expect(result.time.seconds).toBe(''); - expect(result.time.am).toBe(true); - expect(result.time.pm).toBe(false); + expect(result.time.format).toBe('am'); }); it('should parse time with a timezone offset suffix (offset is discarded)', () => { @@ -130,7 +125,30 @@ 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.format).toBe('pm'); + }); + + 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'); }); }); @@ -211,7 +229,7 @@ describe('EdtfService', () => { it('should build year-only EDTF string', () => { const model: DateTimeModel = { date: { year: '1985' }, - time: {}, + time: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985'); @@ -220,7 +238,7 @@ describe('EdtfService', () => { it('should build year-month EDTF string', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985-05'); @@ -229,7 +247,7 @@ describe('EdtfService', () => { it('should build full date EDTF string', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: {}, + time: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985-05-20'); @@ -244,8 +262,7 @@ describe('EdtfService', () => { hours: '02', minutes: '30', seconds: '45', - am: false, - pm: true, + format: 'pm', }, }; @@ -261,8 +278,7 @@ describe('EdtfService', () => { hours: '09', minutes: '15', seconds: '00', - am: true, - pm: false, + format: 'am', }, }; @@ -278,8 +294,7 @@ describe('EdtfService', () => { hours: '12', minutes: '00', seconds: '00', - am: true, - pm: false, + format: 'am', }, }; @@ -295,8 +310,7 @@ describe('EdtfService', () => { hours: '12', minutes: '00', seconds: '00', - am: false, - pm: true, + format: 'pm', }, }; @@ -305,10 +319,26 @@ 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: {}, + time: { format: 'am' }, }; const result = service.toEdtfDate(model); @@ -323,8 +353,7 @@ describe('EdtfService', () => { hours: '02', minutes: '30', seconds: '45', - am: false, - pm: true, + format: 'pm', }, }; @@ -338,7 +367,7 @@ describe('EdtfService', () => { it('should add approximate qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, qualifiers: { approximate: true, uncertain: false, unknown: false }, }; @@ -348,7 +377,7 @@ describe('EdtfService', () => { it('should add uncertain qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, qualifiers: { approximate: false, uncertain: true, unknown: false }, }; @@ -358,7 +387,7 @@ describe('EdtfService', () => { it('should add combined qualifier', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, qualifiers: { approximate: true, uncertain: true, unknown: false }, }; @@ -368,7 +397,7 @@ describe('EdtfService', () => { it('should not add qualifier when none set', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, qualifiers: { approximate: false, uncertain: false, unknown: false }, }; @@ -378,7 +407,7 @@ describe('EdtfService', () => { it('should return XXXX-XX-XX when unknown qualifier is set', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: {}, + time: { format: 'am' }, qualifiers: { approximate: false, uncertain: false, unknown: true }, }; @@ -390,9 +419,9 @@ describe('EdtfService', () => { it('should build a date range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06' }, - endTime: {}, + endTime: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985-05/1990-06'); @@ -401,9 +430,9 @@ describe('EdtfService', () => { it('should build a full date range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06', day: '15' }, - endTime: {}, + endTime: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985-05-20/1990-06-15'); @@ -412,9 +441,9 @@ describe('EdtfService', () => { it('should build a year-only range', () => { const model: DateTimeModel = { date: { year: '1985' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990' }, - endTime: {}, + endTime: { format: 'am' }, }; expect(service.toEdtfDate(model)).toBe('1985/1990'); @@ -423,9 +452,9 @@ describe('EdtfService', () => { it('should apply approximate qualifier to both dates in a range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06' }, - endTime: {}, + endTime: { format: 'am' }, qualifiers: { approximate: true, uncertain: false, unknown: false }, }; @@ -435,9 +464,9 @@ describe('EdtfService', () => { it('should apply uncertain qualifier to both dates in a range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06' }, - endTime: {}, + endTime: { format: 'am' }, qualifiers: { approximate: false, uncertain: true, unknown: false }, }; @@ -447,9 +476,9 @@ describe('EdtfService', () => { it('should apply combined qualifier to both dates in a range', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06' }, - endTime: {}, + endTime: { format: 'am' }, qualifiers: { approximate: true, uncertain: true, unknown: false }, }; @@ -459,9 +488,9 @@ describe('EdtfService', () => { it('should apply qualifier only to the start when the end is open', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, - time: {}, + time: { format: 'am' }, endDate: { year: '', month: '', day: '' }, - endTime: {}, + endTime: { format: 'am' }, qualifiers: { approximate: true, uncertain: false, unknown: false }, }; @@ -471,9 +500,9 @@ describe('EdtfService', () => { it('should apply qualifier to both dates with mixed precision', () => { const model: DateTimeModel = { date: { year: '1985' }, - time: {}, + time: { format: 'am' }, endDate: { year: '1990', month: '06' }, - endTime: {}, + endTime: { format: 'am' }, qualifiers: { approximate: true, uncertain: false, unknown: false }, }; @@ -505,7 +534,7 @@ describe('EdtfService', () => { it('should throw a generic human-readable error for invalid dates', () => { const model: DateTimeModel = { date: { year: '1985', month: '99' }, - time: {}, + time: { format: 'am' }, }; expect(() => service.toEdtfDate(model)).toThrowError( @@ -521,7 +550,7 @@ describe('EdtfService', () => { hours: '02', minutes: '30', seconds: '45', - pm: true, + format: 'pm', }); expect(result).toEqual({ hour: 14, minute: 30, second: 45 }); @@ -532,8 +561,7 @@ describe('EdtfService', () => { hours: '09', minutes: '15', seconds: '00', - am: true, - pm: false, + format: 'am', }); expect(result).toEqual({ hour: 9, minute: 15, second: 0 }); @@ -544,7 +572,7 @@ describe('EdtfService', () => { hours: '12', minutes: '00', seconds: '00', - pm: true, + format: 'pm', }); expect(result).toEqual({ hour: 12, minute: 0, second: 0 }); @@ -555,8 +583,7 @@ describe('EdtfService', () => { hours: '12', minutes: '00', seconds: '00', - am: true, - pm: false, + format: 'am', }); expect(result).toEqual({ hour: 0, minute: 0, second: 0 }); @@ -566,14 +593,14 @@ describe('EdtfService', () => { const result = service.parseTimeAs24Hour({ hours: '05', minutes: '30', - pm: false, + format: 'am', }); expect(result.second).toBe(0); }); it('should default missing hours and minutes to 0', () => { - const result = service.parseTimeAs24Hour({}); + const result = service.parseTimeAs24Hour({ format: 'am' }); expect(result).toEqual({ hour: 0, minute: 0, second: 0 }); }); @@ -583,12 +610,44 @@ describe('EdtfService', () => { hours: '7', minutes: '5', seconds: '3', - am: true, - pm: false, + format: 'am', }); expect(result).toEqual({ hour: 7, minute: 5, second: 3 }); }); + + 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 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 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('isValidHour', () => { @@ -618,6 +677,35 @@ describe('EdtfService', () => { 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', () => { @@ -822,5 +910,13 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); + + it('should roundtrip a full date-time without timezone marker', () => { + const edtfString = '1985-05-20T23:23:23'; + 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 753c404df..247d13efa 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -8,10 +8,13 @@ export enum DateQualifier { Unknown = 'unknown', } -export enum Meridian { - AM = 'AM', - PM = 'PM', -} +export type TimeFormat = 'am' | 'pm' | 'h24'; + +export const TIME_FORMAT_LABEL: Record = { + am: 'AM', + pm: 'PM', + h24: '24H', +}; export enum EdtfPrecision { Time = 0, @@ -26,8 +29,7 @@ export const DEFAULT_TIME: TimeModel = { hours: '', minutes: '', seconds: '', - am: true, - pm: false, + format: 'am', }; export interface DateQualifierFlags { @@ -46,8 +48,7 @@ export interface TimeModel { hours?: string; minutes?: string; seconds?: string; - am?: boolean; - pm?: boolean; + format: TimeFormat; } export interface DateTimeModel { @@ -87,7 +88,7 @@ export class EdtfService { return null; } - return this.extDateToDateTimeModel(edtfObject); + return this.extDateToDateTimeModel(edtfObject, edtfString); } catch (error) { throw new Error(this.toHumanReadableError(error)); } @@ -108,7 +109,7 @@ export class EdtfService { return null; } - return this.intervalToDateTimeModel(edtfObject); + return this.intervalToDateTimeModel(edtfObject, startPart, endPart); } catch { return null; } @@ -211,7 +212,10 @@ export class EdtfService { return `T${pad(converted.hour)}:${pad(converted.minute)}:${pad(converted.second)}`; } - private extDateToDateTimeModel(extDate: EdtfDate): DateTimeModel { + private extDateToDateTimeModel( + extDate: EdtfDate, + rawEdtfString: string, + ): DateTimeModel { const edtfStr = extDate.toEDTF(); const precision = extDate.precision; @@ -237,7 +241,15 @@ export class EdtfService { const day = dayPart === 'XX' ? '' : (dayPart ?? ''); 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; @@ -254,14 +266,25 @@ export class EdtfService { }, time: { hours: hasTime ? String(hours12).padStart(2, '0') : '', - minutes: hasTime ? String(extDate.minutes).padStart(2, '0') : '', - seconds: hasTime ? String(extDate.seconds).padStart(2, '0') : '', - am: hasTime ? !isPm : true, - pm: hasTime ? isPm : false, + minutes: hasTime ? String(minutes).padStart(2, '0') : '', + seconds: hasTime ? String(seconds).padStart(2, '0') : '', + format: hasTime && isPm ? 'pm' : 'am', }, }; } + private extractRawTime( + edtfString: string, + ): { hours: number; minutes: number; seconds: number } | null { + const match = edtfString.match(/T(\d{2}):(\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, + }; + } + private isEmptyDateTime(date: DateModel, time: TimeModel): boolean { return !date.year && !date.month && !date.day && !time?.hours; } @@ -280,7 +303,11 @@ export class EdtfService { return 'The date entered is not valid. Please check the values and try again.'; } - private intervalToDateTimeModel(interval: EdtfInterval): DateTimeModel { + private intervalToDateTimeModel( + interval: EdtfInterval, + startRaw: string, + endRaw: string, + ): DateTimeModel { const lower = interval.lower; const upper = interval.upper; @@ -288,14 +315,14 @@ export class EdtfService { const openEnd = typeof upper === 'number' || upper === null; const model: DateTimeModel = openStart - ? { date: { year: '' } as DateModel, time: {} as TimeModel } - : this.extDateToDateTimeModel(lower); + ? { date: { year: '' } as DateModel, time: { format: 'am' } } + : this.extDateToDateTimeModel(lower, startRaw); if (openEnd) { model.endDate = { year: '' } as DateModel; - model.endTime = {} as TimeModel; + model.endTime = { format: 'am' }; } else if (upper) { - const upperModel = this.extDateToDateTimeModel(upper); + const upperModel = this.extDateToDateTimeModel(upper, endRaw); model.endDate = upperModel.date; model.endTime = upperModel.time; } @@ -306,12 +333,27 @@ export class EdtfService { 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 minutesInput = time.minutes || '0'; - const secondsInput = time.seconds || '0'; - const meridian = time.pm === true ? 'PM' : 'AM'; + const meridian = time.format === 'pm' ? 'PM' : 'AM'; const parsed = parse( `${hoursInput}:${minutesInput}:${secondsInput} ${meridian}`, @@ -328,7 +370,12 @@ export class EdtfService { }; } - isValidHour(value: string): boolean { + 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())); From 5658ace473d951170cf0ec6fefb4a65a7c4fde45 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 4 Jun 2026 10:39:18 +0300 Subject: [PATCH 3/5] Support unspecified digits for date When parsing the input for date, if there are missing digits, we will fill those with X values. Issue: PER-10632 --- .../datepicker-input.component.spec.ts | 8 +- .../datepicker-input.component.ts | 4 +- .../edtf-service/edtf.service.spec.ts | 151 ++++++++++++++++++ .../services/edtf-service/edtf.service.ts | 27 ++-- 4 files changed, 172 insertions(+), 18 deletions(-) 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 index 50e3472e0..509dbcb67 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts @@ -52,10 +52,10 @@ describe('DatepickerInputComponent', () => { expect(hostComponent.lastEmittedDate?.year).toBe('2026'); }); - it('should not emit for incomplete year', () => { + it('should emit incomplete year as raw digits (no X in input)', () => { component.updateYear(mockEvent('202')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(hostComponent.lastEmittedDate?.year).toBe('202'); }); it('should reject non-numeric year', () => { @@ -167,10 +167,10 @@ describe('DatepickerInputComponent', () => { expect(hostComponent.lastEmittedDate.year).toBe(''); }); - it('should allow typing intermediate digits without emitting', () => { + it('should emit intermediate digits as the user types the year', () => { component.updateYear(mockEvent('19')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(hostComponent.lastEmittedDate?.year).toBe('19'); }); it('should toggle datepicker', () => { diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.ts index 55850bbde..5cdb954fe 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.ts +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.ts @@ -85,11 +85,9 @@ export class DatepickerInputComponent implements OnInit, OnChanges { return; } + this.dateChange.emit({ ...this.date, year: value }); if (value.length === 4) { - this.dateChange.emit({ ...this.date, year: value }); this.monthInput.nativeElement.focus(); - } else if (value.length === 0) { - this.dateChange.emit({ ...this.date, year: value }); } } 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 205bb38dc..ce8f10276 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -77,6 +77,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', () => { @@ -254,6 +291,80 @@ describe('EdtfService', () => { }); }); + 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: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('19XX'); + }); + + 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: '1985', month: '', day: '20' }, + time: { format: 'am' }, + }; + + 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'); + }); + }); + describe('time building', () => { it('should build date with PM time', () => { const model: DateTimeModel = { @@ -918,5 +1029,45 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); + + 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 247d13efa..c7b957acb 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -184,22 +184,27 @@ export class EdtfService { } private buildDateString(date: DateModel): string { - const year = date.year.padStart(4, '0'); + const hasYear = !!date.year; + const hasMonth = !!date.month; + const hasDay = !!date.day; - if (!date.month) { - return year; - } + if (!hasYear && !hasMonth && !hasDay) return ''; - const month = date.month.padStart(2, '0'); + const year = this.padWithX(date.year, 4); + if (!hasMonth && !hasDay) return year; - if (!date.day) { - return `${year}-${month}`; - } + const month = hasMonth ? this.padWithX(date.month, 2) : 'XX'; + if (!hasDay) return `${year}-${month}`; - const day = date.day.padStart(2, '0'); + const day = this.padWithX(date.day, 2); return `${year}-${month}-${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 ''; @@ -237,8 +242,8 @@ export class EdtfService { .substring(0, 2) : undefined; - const month = monthPart === 'XX' ? '' : (monthPart ?? ''); - const day = dayPart === 'XX' ? '' : (dayPart ?? ''); + const month = (monthPart ?? '').replace(/X+$/i, ''); + const day = (dayPart ?? '').replace(/X+$/i, ''); const hasTime = precision === EdtfPrecision.Time || edtfStr.includes('T'); // Read the wall-clock time from the original input string instead of From 43febc5e0a33f07c79af7a5efcdb68101cc1cfef Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 4 Jun 2026 14:23:52 +0300 Subject: [PATCH 4/5] Add a11y for the date and time components Made the elements accessible by keyboard Issue: PER-10632 --- .../datepicker-input/datepicker-input.component.html | 4 ++++ .../timepicker-input/timepicker-input.component.html | 4 ++++ .../timezone-dropdown.component.html | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.html b/src/app/shared/components/datepicker-input/datepicker-input.component.html index 90341844e..01c69a8d7 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.html +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.html @@ -36,8 +36,12 @@
calendar_today diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.html b/src/app/shared/components/timepicker-input/timepicker-input.component.html index 128687e70..d23a855a1 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.html +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html @@ -39,8 +39,12 @@
access_time diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html index 96af7d73d..8d161921b 100644 --- a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html @@ -2,7 +2,11 @@
{{ selected?.offset }} {{ @@ -24,16 +28,24 @@
Select timezone
@for (tz of filteredTimezones(); track tz.ianaZone) {
{{ tz.offset }} {{ tz.label }} From e00e0ae6df42eeee74603f31b8443cdd000c062b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 11 Jun 2026 19:33:09 +0300 Subject: [PATCH 5/5] Preserve timezone offsets when saving times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the ±hh:mm offset of a parsed displayTime in the model and re-append it on save; newly entered times are stamped with the user's local offset. Also fix a date shift where unmarked times were treated as browser-local and converted to UTC during parsing. Issue: PER-10632 --- .../timepicker-input.component.spec.ts | 14 +++ .../timepicker-input.component.ts | 2 + .../edtf-service/edtf.service.spec.ts | 91 +++++++++++++++++-- .../services/edtf-service/edtf.service.ts | 40 ++++++-- 4 files changed, 133 insertions(+), 14 deletions(-) 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 index 6d6166918..a172e3a31 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts @@ -490,4 +490,18 @@ describe('TimepickerInputComponent', () => { 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 index fbe1809dd..6ce01d446 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts @@ -100,6 +100,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (this.is24Hour()) { this.timeChange.emit({ + ...this.time, hours: String(ngbTime.hour).padStart(2, '0'), minutes, seconds, @@ -112,6 +113,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { const displayHour = ngbTime.hour % 12 || 12; this.timeChange.emit({ + ...this.time, hours: String(displayHour).padStart(2, '0'), minutes, seconds, 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 ce8f10276..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; @@ -156,7 +167,7 @@ describe('EdtfService', () => { expect(result.time.format).toBe('am'); }); - it('should parse time with a timezone offset suffix (offset is discarded)', () => { + 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.hours).toBe('02'); @@ -165,6 +176,39 @@ describe('EdtfService', () => { expect(result.time.format).toBe('pm'); }); + 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'); + }); + + 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(unmarked.time.timezoneOffset).toBeUndefined(); + expect(utcMarked.time.timezoneOffset).toBeUndefined(); + }); + + 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.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 @@ -250,13 +294,15 @@ describe('EdtfService', () => { expect(result.endDate.month).toBe(''); }); - it('should parse an interval with timezone suffixes (offsets are discarded)', () => { + 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('1990'); + expect(result.time.timezoneOffset).toBe('+05:30'); + expect(result.endTime.timezoneOffset).toBe('-04:00'); }); }); }); @@ -457,7 +503,24 @@ describe('EdtfService', () => { expect(result).not.toContain('T'); }); - it('should not append any timezone suffix to the serialized EDTF string', () => { + 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', + format: 'pm', + timezoneOffset: '+05:30', + }, + }; + + const result = service.toEdtfDate(model); + + expect(result).toBe('1985-05-20T14:30:45+05:30'); + }); + + it('should stamp the local timezone offset when the model has none', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, time: { @@ -470,7 +533,7 @@ describe('EdtfService', () => { const result = service.toEdtfDate(model); - expect(result).toBe('1985-05-20T14:30:45'); + expect(result).toBe(`1985-05-20T14:30:45${localTimezoneOffset()}`); }); }); @@ -1022,14 +1085,30 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); - it('should roundtrip a full date-time without timezone marker', () => { - const edtfString = '1985-05-20T23:23:23'; + 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); diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index c7b957acb..5af5b3b68 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -49,6 +49,7 @@ export interface TimeModel { minutes?: string; seconds?: string; format: TimeFormat; + timezoneOffset?: string; } export interface DateTimeModel { @@ -116,9 +117,15 @@ export class EdtfService { } private normalizeForParsing(edtfString: string): string { - // Replace +hh:mm / -hh:mm timezone offset with Z so the edtf library - // can parse it — the library requires a UTC designator and throws on offsets. - return edtfString.replace(/T([\d:]+)[+-]\d{2}:\d{2}/, 'T$1Z'); + // 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 { @@ -213,8 +220,18 @@ export class EdtfService { throw new Error('Invalid time'); } + 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)}`; + return `T${pad(converted.hour)}:${pad(converted.minute)}:${pad(converted.second)}${timezoneOffset}`; + } + + 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( @@ -274,19 +291,26 @@ export class EdtfService { 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 } | null { - const match = edtfString.match(/T(\d{2}):(\d{2})(?::(\d{2}))?/); + 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], }; }