From 8fce365eed6a260a6ff325ee79ae3e2d7ea4fbc0 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sat, 12 Sep 2026 16:06:10 +0300 Subject: [PATCH 1/4] Update the IANA timezone dropdown and add a timezone mapper service The already existing timezone was not wired to anything and it did not match the current design, so it needed to be udpated. The TimezoneService reads the tz database the browser already ships rather than carrying a list of our own, so it stays current as the database does. Also, it maps the cumbersome fields we are getting from the TZ database into clear easy to use properties for our dropdown. Issue: PER-10623 --- .../timezone-dropdown.component.html | 160 +++++-- .../timezone-dropdown.component.scss | 188 +++++--- .../timezone-dropdown.component.spec.ts | 451 +++++++++++++++--- .../timezone-dropdown.component.ts | 375 ++++++++++++--- .../timezone-service/timezone.service.spec.ts | 344 +++++++++++++ .../timezone-service/timezone.service.ts | 386 +++++++++++++++ 6 files changed, 1640 insertions(+), 264 deletions(-) create mode 100644 src/app/shared/services/timezone-service/timezone.service.spec.ts create mode 100644 src/app/shared/services/timezone-service/timezone.service.ts 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 8d161921b..65f49128f 100644 --- a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html @@ -1,57 +1,121 @@ -
-
+
+ @if (selectedOption(); as option) { + {{ option.offsetLabel }} + {{ option.timezoneId }} + } @else { + {{ placeholder }} + } + + + @if (isOpen()) { -
- -
-
- Select timezone -
- @for (tz of filteredTimezones(); track tz.ianaZone) { -
+ @if (showSearch()) { + + } + +
    + @if (showClearOption()) { +
  • - {{ tz.offset }} - {{ tz.label }} -
+ {{ + placeholder + }} + } -
+ + @for (group of indexedGroups(); track group.region) { +
  • + {{ group.region }} + +
  • + } @empty { + + } +
    }
    diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss index efda26819..2117bef7a 100644 --- a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss @@ -1,49 +1,63 @@ @import 'colors'; @import 'mixins'; -.pr-timezone-row { +.pr-timezone-dropdown { position: relative; - .pr-timezone-select { + .pr-timezone-trigger { display: flex; align-items: center; + width: 100%; background: $white; border: 1px solid $PR-blue-100; border-radius: 8px; padding: 0 12px; height: 40px; cursor: pointer; + text-align: left; - &:hover { + &:hover:not(:disabled) { border-color: $PR-blue-300; } - &.disabled { - opacity: 0.5; - pointer-events: none; + &:focus-visible { + outline: 2px solid $PR-blue-400; + outline-offset: 1px; } - .pr-timezone-value { - font-size: 14px; - font-weight: 600; - color: $PR-blue-900; - margin-right: 8px; + &:disabled { + opacity: 0.5; + cursor: default; } + } - .pr-timezone-label { - @include usual-text; - color: $PR-blue-400; - } + .pr-timezone-offset { + font-size: 14px; + font-weight: 600; + color: $PR-blue-900; + margin-right: 8px; + white-space: nowrap; + } - .pr-chevron { - margin-left: auto; - color: $PR-blue-400; - display: flex; - align-items: center; - } + .pr-timezone-label, + .pr-timezone-placeholder { + @include usual-text; + + color: $PR-blue-400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .pr-timezone-dropdown { + .pr-timezone-chevron { + margin-left: auto; + padding-left: 8px; + color: $PR-blue-400; + display: flex; + align-items: center; + } + + .pr-timezone-panel { position: absolute; top: 52px; left: 0; @@ -54,54 +68,98 @@ 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-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, + .pr-timezone-group-options { + list-style: none; + margin: 0; + padding: 0; + } + + .pr-timezone-options { + max-height: 260px; + overflow-y: auto; + } - .pr-timezone-options { - max-height: 200px; - overflow-y: auto; + .pr-timezone-region { + display: block; + padding: 10px 12px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: $PR-blue-400; + } + + .pr-timezone-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.1s ease; + + &:hover, + &.pr-timezone-option-active { + background: $PR-blue-25; } - .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; - } + &.pr-timezone-option-selected { + background: $PR-blue-50; } } + + .pr-timezone-option-offset { + font-size: 14px; + font-weight: 600; + color: $PR-blue-900; + min-width: 90px; + } + + .pr-timezone-option-label { + @include usual-text; + + color: $PR-blue-900; + white-space: nowrap; + } + + .pr-timezone-option-placeholder { + @include usual-text; + + color: $PR-blue-400; + } + + .pr-timezone-option-country { + @include usual-text; + + margin-left: auto; + padding-left: 12px; + color: $PR-blue-400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .pr-timezone-empty { + @include usual-text; + + padding: 12px; + 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 index fc65d302e..a3903adc2 100644 --- a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts @@ -1,12 +1,31 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { + TIMEZONE_PLACEHOLDER, TimezoneDropdownComponent, - TimezoneOption, } from './timezone-dropdown.component'; describe('TimezoneDropdownComponent', () => { - let component: TimezoneDropdownComponent; let fixture: ComponentFixture; + let instance: TimezoneDropdownComponent; + let element: HTMLElement; + + const setInput = (name: string, value: unknown): void => { + fixture.componentRef.setInput(name, value); + fixture.detectChanges(); + }; + + const trigger = (): HTMLButtonElement => + element.querySelector('.pr-timezone-trigger'); + + const listbox = (): HTMLElement => element.querySelector('[role="listbox"]'); + + const options = (): HTMLElement[] => + Array.from(element.querySelectorAll('[role="option"]')); + + const pressKey = (target: HTMLElement, key: string): void => { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + fixture.detectChanges(); + }; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -14,113 +33,393 @@ describe('TimezoneDropdownComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(TimezoneDropdownComponent); - component = fixture.componentInstance; + instance = fixture.componentInstance; + element = fixture.nativeElement; fixture.detectChanges(); }); it('should create', () => { - expect(component).toBeTruthy(); + expect(instance).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(); - }); + describe('selected value', () => { + it('should show the placeholder when nothing is selected', () => { + expect(trigger().textContent).toContain(TIMEZONE_PLACEHOLDER); + }); - // --- Toggle behaviour --- + it('should show the offset and the tz identifier for a valid identifier', () => { + setInput('selectedTimezone', 'Europe/Bucharest'); - it('should open dropdown on toggle', () => { - expect(component.isOpen()).toBeFalse(); - component.toggle(); + expect( + element.querySelector('.pr-timezone-offset').textContent.trim(), + ).toMatch(/^GMT[+-]\d{2}:\d{2}$/); - expect(component.isOpen()).toBeTrue(); - }); + expect( + element.querySelector('.pr-timezone-label').textContent.trim(), + ).toEqual('Europe/Bucharest'); + }); - it('should close dropdown on second toggle', () => { - component.toggle(); - component.toggle(); + it('should canonicalize the identifier it displays', () => { + setInput('selectedTimezone', ' europe/berlin '); - expect(component.isOpen()).toBeFalse(); - }); + expect( + element.querySelector('.pr-timezone-label').textContent.trim(), + ).toEqual('Europe/Berlin'); + }); - it('should not open when disabled', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - component.toggle(); + it('should fall back to the placeholder for unusable values', () => { + [null, undefined, '', 'Not/AZone', 123, {}].forEach((value) => { + setInput('selectedTimezone', value); - expect(component.isOpen()).toBeFalse(); - }); + expect(trigger().textContent).toContain(TIMEZONE_PLACEHOLDER); + }); + }); - it('should reset filter when opening', () => { - component.filter.set('pacific'); - component.toggle(); + it('should render an identifier that is missing from the supported list', () => { + setInput('selectedTimezone', 'UTC'); - expect(component.filter()).toBe(''); - }); + expect(trigger().textContent).toContain('GMT+00:00'); + }); - it('should reset filter when closing', () => { - component.toggle(); - component.filter.set('pacific'); - component.close(); + it('should make an unlisted selection available in the list', () => { + setInput('selectedTimezone', 'UTC'); + trigger().click(); + fixture.detectChanges(); - expect(component.filter()).toBe(''); + expect( + instance.visibleOptions().some((option) => option.timezoneId === 'UTC'), + ).toBeTrue(); + }); }); - // --- Filtering --- + describe('accessibility', () => { + it('should mark the trigger as the combobox when there is no search field', () => { + expect(trigger().getAttribute('role')).toEqual('combobox'); + expect(trigger().getAttribute('aria-expanded')).toEqual('false'); + expect(trigger().getAttribute('aria-haspopup')).toEqual('listbox'); + }); + + it('should point the trigger at the listbox once open', () => { + trigger().click(); + fixture.detectChanges(); + + expect(trigger().getAttribute('aria-expanded')).toEqual('true'); + expect(trigger().getAttribute('aria-controls')).toEqual( + listbox().getAttribute('id'), + ); + }); + + it('should hand the combobox role to the search field when one is shown', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + const searchInput = element.querySelector('.pr-timezone-search'); + + expect(trigger().getAttribute('role')).toBeNull(); + expect(searchInput.getAttribute('role')).toEqual('combobox'); + expect(searchInput.getAttribute('aria-controls')).toEqual( + listbox().getAttribute('id'), + ); + }); - it('should return all timezones when filter is empty', () => { - component.filter.set(''); + it('should move focus to the search field once the panel has rendered', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); - expect(component.filteredTimezones().length).toBe( - component.timezones.length, - ); + expect(document.activeElement).toBe( + element.querySelector('.pr-timezone-search'), + ); + }); + + it('should group the options by region', () => { + trigger().click(); + fixture.detectChanges(); + const groups = element.querySelectorAll('[role="group"]'); + + expect(groups.length).toBeGreaterThan(1); + groups.forEach((group) => { + const labelId = group.getAttribute('aria-labelledby'); + + expect(element.querySelector(`#${labelId}`)).toBeTruthy(); + }); + }); + + it('should label each option with its tz identifier', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + instance.onSearchTermChange('bucharest'); + fixture.detectChanges(); + + expect( + options()[0] + .querySelector('.pr-timezone-option-label') + .textContent.trim(), + ).toEqual('Europe/Bucharest'); + }); + + it('should show the country beside the identifier', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + instance.onSearchTermChange('accra'); + fixture.detectChanges(); + + expect( + options()[0] + .querySelector('.pr-timezone-option-country') + .textContent.trim(), + ).toEqual('Ghana'); + }); + + it('should find a zone by its country name', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + instance.onSearchTermChange('romania'); + fixture.detectChanges(); + + expect( + instance.visibleOptions().map((option) => option.timezoneId), + ).toContain('Europe/Bucharest'); + }); + + it('should keep the options out of the tab order', () => { + trigger().click(); + fixture.detectChanges(); + + expect( + options().every((option) => option.getAttribute('tabindex') === '-1'), + ).toBeTrue(); + }); + + it('should mark only the selected option as selected', () => { + setInput('selectedTimezone', 'Europe/Bucharest'); + trigger().click(); + fixture.detectChanges(); + const selected = options().filter( + (option) => option.getAttribute('aria-selected') === 'true', + ); + + expect(selected.length).toEqual(1); + }); + + it('should track the active option with aria-activedescendant', () => { + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'ArrowDown'); + const activeDescendantId = trigger().getAttribute( + 'aria-activedescendant', + ); + + expect(activeDescendantId).toBeTruthy(); + expect(element.querySelector(`#${activeDescendantId}`)).toBeTruthy(); + }); }); - 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(); + describe('keyboard interaction', () => { + it('should open on ArrowDown and ArrowUp', () => { + pressKey(trigger(), 'ArrowDown'); + + expect(instance.isOpen()).toBeTrue(); + + instance.close(); + fixture.detectChanges(); + pressKey(trigger(), 'ArrowUp'); + + expect(instance.isOpen()).toBeTrue(); + }); + + it('should move the active option with the arrow keys', () => { + trigger().click(); + fixture.detectChanges(); + const firstActive = instance.activeOption(); + pressKey(trigger(), 'ArrowDown'); + const secondActive = instance.activeOption(); + + expect(secondActive).not.toBe(firstActive); + + pressKey(trigger(), 'ArrowUp'); + + expect(instance.activeOption()).toBe(firstActive); + }); + + it('should jump to the first and last option with Home and End', () => { + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'End'); + + expect(instance.activeOption()).toBe( + instance.visibleOptions()[instance.visibleOptions().length - 1], + ); + + pressKey(trigger(), 'Home'); + + expect(instance.activeOption()).toBe(instance.visibleOptions()[0]); + }); + + it('should emit the identifier of the active option on Enter', () => { + const emitted: string[] = []; + instance.timezoneChange.subscribe((value) => emitted.push(value)); + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'ArrowDown'); + const activeTimezoneId = instance.activeOption().timezoneId; + pressKey(trigger(), 'Enter'); + + expect(emitted).toEqual([activeTimezoneId]); + expect(instance.isOpen()).toBeFalse(); + }); + + it('should close and return focus to the trigger on Escape', () => { + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'Escape'); + + expect(instance.isOpen()).toBeFalse(); + expect(document.activeElement).toBe(trigger()); + }); + + it('should close on Tab without swallowing the key', () => { + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'Tab'); + + expect(instance.isOpen()).toBeFalse(); + }); }); - it('should filter timezones by offset', () => { - component.filter.set('gmt+09'); - const filtered = component.filteredTimezones(); + describe('empty state', () => { + it('should offer a clear row labelled with the placeholder', () => { + trigger().click(); + fixture.detectChanges(); + + expect( + options()[0] + .querySelector('.pr-timezone-option-placeholder') + .textContent.trim(), + ).toEqual(TIMEZONE_PLACEHOLDER); + }); + + it('should emit null when the clear row is chosen', () => { + const emitted: (string | null)[] = []; + instance.timezoneChange.subscribe((value) => emitted.push(value)); + setInput('selectedTimezone', 'Europe/Bucharest'); + trigger().click(); + fixture.detectChanges(); + options()[0].click(); + fixture.detectChanges(); - expect(filtered.length).toBeGreaterThan(0); - expect( - filtered.every((tz) => tz.offset.toLowerCase().includes('gmt+09')), - ).toBeTrue(); + expect(emitted).toEqual([null]); + }); + + it('should mark the clear row as selected while nothing is chosen', () => { + trigger().click(); + fixture.detectChanges(); + + expect(options()[0].getAttribute('aria-selected')).toEqual('true'); + }); + + it('should mark the clear row unselected once a zone is chosen', () => { + setInput('selectedTimezone', 'Europe/Bucharest'); + trigger().click(); + fixture.detectChanges(); + + expect(options()[0].getAttribute('aria-selected')).toEqual('false'); + }); + + it('should hide the clear row while searching so arrows land on a match', () => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + instance.onSearchTermChange('bucharest'); + fixture.detectChanges(); + + expect(instance.visibleOptions()[0].timezoneId).toEqual( + 'Europe/Bucharest', + ); + + expect(element.querySelector('.pr-timezone-option-clear')).toBeNull(); + }); + + it('should clear via the keyboard', () => { + const emitted: (string | null)[] = []; + instance.timezoneChange.subscribe((value) => emitted.push(value)); + setInput('selectedTimezone', 'Europe/Bucharest'); + trigger().click(); + fixture.detectChanges(); + pressKey(trigger(), 'Home'); + pressKey(trigger(), 'Enter'); + + expect(emitted).toEqual([null]); + }); }); - // --- Selection --- + describe('selection', () => { + it('should emit the identifier when an option is clicked', () => { + const emitted: (string | null)[] = []; + instance.timezoneChange.subscribe((value) => emitted.push(value)); + trigger().click(); + fixture.detectChanges(); + // Index 0 is the clear row, so the first real zone sits after it. + const firstTimezoneId = instance.visibleOptions()[1].timezoneId; + options()[1].click(); + fixture.detectChanges(); - it('should emit timezoneChange and close on select', () => { - spyOn(component.timezoneChange, 'emit'); - component.toggle(); + expect(emitted).toEqual([firstTimezoneId]); + expect(instance.isOpen()).toBeFalse(); + }); - const tz: TimezoneOption = component.timezones[0]; - component.select(tz); + it('should not open while disabled', () => { + setInput('disabled', true); + trigger().click(); + pressKey(trigger(), 'ArrowDown'); - expect(component.timezoneChange.emit).toHaveBeenCalledWith(tz); - expect(component.isOpen()).toBeFalse(); - expect(component.filter()).toBe(''); + expect(instance.isOpen()).toBeFalse(); + }); }); - it('should emit null for placeholder selection', () => { - spyOn(component.timezoneChange, 'emit'); - component.select(null); + describe('search', () => { + beforeEach(() => { + setInput('showSearch', true); + trigger().click(); + fixture.detectChanges(); + }); + + it('should filter the options by city, region and offset', () => { + instance.onSearchTermChange('bucharest'); + fixture.detectChanges(); + + expect(instance.visibleOptions().length).toEqual(1); + expect(instance.visibleOptions()[0].timezoneId).toEqual( + 'Europe/Bucharest', + ); + }); + + it('should show a message when nothing matches', () => { + instance.onSearchTermChange('nowhere at all'); + fixture.detectChanges(); + + expect(instance.visibleOptions().length).toEqual(0); + expect(element.querySelector('.pr-timezone-empty')).toBeTruthy(); + }); + + it('should reset the active option when the term changes', () => { + pressKey(trigger(), 'End'); + instance.onSearchTermChange('europe'); + fixture.detectChanges(); + + expect(instance.activeOptionIndex()).toEqual(0); + }); + + it('should let a space reach the search field', () => { + const emitted: string[] = []; + instance.timezoneChange.subscribe((value) => emitted.push(value)); + pressKey(element.querySelector('.pr-timezone-search'), ' '); - expect(component.timezoneChange.emit).toHaveBeenCalledWith(null); + expect(emitted).toEqual([]); + expect(instance.isOpen()).toBeTrue(); + }); }); }); diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts index a059e84ad..3527fb67f 100644 --- a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts @@ -1,118 +1,343 @@ import { Component, - Input, - Output, - EventEmitter, - signal, - computed, ElementRef, - ViewChild, HostListener, + computed, + effect, + inject, + input, + output, + signal, + viewChild, } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { + TimezoneGroup, + TimezoneOption, + TimezoneService, +} from '@shared/services/timezone-service/timezone.service'; -export interface TimezoneOption { - ianaZone: string; - label: string; - offset: string; - abbreviation: string; -} +export const TIMEZONE_PLACEHOLDER = 'Select timezone'; +export const NO_MATCHES_MESSAGE = 'No matching timezones'; -function getSupportedIanaZones(): string[] { - const supportedValuesOf = ( - Intl as unknown as { - supportedValuesOf?: (key: string) => string[]; - } - ).supportedValuesOf; - return typeof supportedValuesOf === 'function' - ? supportedValuesOf('timeZone') - : []; +export interface IndexedTimezoneOption { + option: TimezoneOption; + index: number; } -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 ''; - } +export interface IndexedTimezoneGroup { + region: string; + options: IndexedTimezoneOption[]; } -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), - })); -} +/** Stands for "no timezone" so clearing is reachable by mouse and keyboard + * alike; an empty identifier is what marks it apart from a real zone. */ +const CLEAR_OPTION: TimezoneOption = { + timezoneId: '', + offsetLabel: '', + region: '', + countryName: '', + searchText: '', +}; -const TIMEZONE_OPTIONS: TimezoneOption[] = buildTimezoneOptions(); +let instanceCount = 0; @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), - ); + readonly selectedTimezone = input(undefined); + readonly disabled = input(false); + readonly showSearch = input(false); + readonly fieldLabel = input('Timezone'); + + readonly timezoneChange = output(); + + private readonly triggerButton = + viewChild>('triggerButton'); + private readonly searchInput = + viewChild>('searchInput'); + + readonly isOpen = signal(false); + readonly searchTerm = signal(''); + readonly activeOptionIndex = signal(0); + + readonly placeholder = TIMEZONE_PLACEHOLDER; + readonly noMatchesMessage = NO_MATCHES_MESSAGE; + readonly elementIdPrefix = `pr-timezone-dropdown-${(instanceCount += 1)}`; + readonly listboxElementId = `${this.elementIdPrefix}-listbox`; + + private readonly timezoneService = inject(TimezoneService); + private readonly elementRef = inject(ElementRef); + + readonly selectedOption = computed(() => + this.timezoneService.getOption(this.selectedTimezone()), + ); + + readonly groups = computed(() => { + const searchTerm = this.searchTerm().trim().toLowerCase(); + if (!searchTerm) { + return this.selectableGroups(); + } + return this.selectableGroups() + .map((group) => ({ + region: group.region, + options: group.options.filter((option) => + option.searchText.includes(searchTerm), + ), + })) + .filter((group) => group.options.length > 0); }); + /** Hidden while searching so the first arrow-down lands on a match rather + * than on the clear row. */ + readonly showClearOption = computed(() => !this.searchTerm().trim()); + + readonly clearOption = CLEAR_OPTION; + + readonly visibleOptions = computed(() => { + const options = this.groups().flatMap((group) => group.options); + return this.showClearOption() ? [CLEAR_OPTION, ...options] : options; + }); + + /** + * Each option carries its position in the flattened list so the template can + * bind element ids and the active row without searching the list per option. + */ + readonly indexedGroups = computed(() => { + let nextOptionIndex = this.showClearOption() ? 1 : 0; + return this.groups().map((group) => ({ + region: group.region, + options: group.options.map((option) => { + const index = nextOptionIndex; + nextOptionIndex += 1; + return { option, index }; + }), + })); + }); + + readonly activeOption = computed( + () => this.visibleOptions()[this.clampedActiveOptionIndex()] ?? null, + ); + + readonly activeDescendantId = computed(() => + this.isOpen() && this.activeOption() + ? this.buildOptionElementId(this.clampedActiveOptionIndex()) + : null, + ); + + /** + * Intl.supportedValuesOf omits identifiers it still accepts elsewhere, so a + * selection missing from the list is folded in rather than left unselectable. + */ + private readonly selectableGroups = computed(() => { + const groups = this.timezoneService.getGroupedOptions(); + const selectedOption = this.selectedOption(); + const isListed = + !selectedOption || + groups.some((group) => + group.options.some( + (option) => option.timezoneId === selectedOption.timezoneId, + ), + ); + if (isListed) { + return groups; + } + return groups.some((group) => group.region === selectedOption.region) + ? groups.map((group) => + group.region === selectedOption.region + ? { ...group, options: [selectedOption, ...group.options] } + : group, + ) + : [ + { region: selectedOption.region, options: [selectedOption] }, + ...groups, + ]; + }); + + constructor() { + // The search field only exists once the panel has rendered, so focus has + // to wait for the view child rather than move inside open(). + effect(() => { + if (this.isOpen() && this.showSearch()) { + this.searchInput()?.nativeElement.focus(); + } + }); + } + @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { const target = event.target as Node; - if (!this.dropdownContainer?.nativeElement.contains(target)) { + if ( + this.isOpen() && + target.isConnected && + !this.elementRef.nativeElement.contains(target) + ) { this.close(); } } + isOptionActive(optionIndex: number): boolean { + return this.isOpen() && optionIndex === this.clampedActiveOptionIndex(); + } + + isOptionSelected(option: TimezoneOption): boolean { + return option.timezoneId === (this.selectedOption()?.timezoneId ?? ''); + } + + buildOptionElementId(optionIndex: number): string { + return `${this.elementIdPrefix}-option-${optionIndex}`; + } + + buildRegionElementId(region: string): string { + return `${this.elementIdPrefix}-region-${region.replace(/\s+/g, '-')}`; + } + toggle(): void { - if (this.disabled) return; + if (this.disabled()) { + return; + } if (this.isOpen()) { this.close(); } else { - this.isOpen.set(true); - this.filter.set(''); + this.open(); } } + open(): void { + if (this.disabled()) { + return; + } + this.searchTerm.set(''); + this.activeOptionIndex.set(Math.max(0, this.indexOfSelectedOption())); + this.isOpen.set(true); + } + close(): void { this.isOpen.set(false); - this.filter.set(''); + this.searchTerm.set(''); } - select(tz: TimezoneOption | null): void { - this.timezoneChange.emit(tz); + closeAndRefocusTrigger(): void { this.close(); + this.triggerButton()?.nativeElement.focus(); + } + + onSearchTermChange(searchTerm: string): void { + this.searchTerm.set(searchTerm); + this.activeOptionIndex.set(0); + } + + selectOption(option: TimezoneOption): void { + this.timezoneChange.emit(option.timezoneId || null); + this.closeAndRefocusTrigger(); + } + + onKeydown(event: KeyboardEvent): void { + if (this.disabled()) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.isOpen() ? this.moveActiveOption(1) : this.open(); + break; + case 'ArrowUp': + event.preventDefault(); + this.isOpen() ? this.moveActiveOption(-1) : this.open(); + break; + case 'Home': + if (this.isOpen()) { + event.preventDefault(); + this.setActiveOptionIndex(0); + } + break; + case 'End': + if (this.isOpen()) { + event.preventDefault(); + this.setActiveOptionIndex(this.visibleOptions().length - 1); + } + break; + case 'Enter': + event.preventDefault(); + this.isOpen() ? this.selectActiveOption() : this.open(); + break; + case ' ': + // While the search field has focus a space has to reach the input. + if (!this.showSearch() || !this.isOpen()) { + event.preventDefault(); + this.isOpen() ? this.selectActiveOption() : this.open(); + } + break; + case 'Escape': + if (this.isOpen()) { + event.preventDefault(); + this.closeAndRefocusTrigger(); + } + break; + // Tab closes the panel but must still move focus, so it is not + // prevented. + case 'Tab': + this.close(); + break; + default: + break; + } + } + + private selectActiveOption(): void { + const activeOption = this.activeOption(); + if (activeOption) { + this.selectOption(activeOption); + } else { + this.closeAndRefocusTrigger(); + } + } + + private moveActiveOption(step: number): void { + const optionCount = this.visibleOptions().length; + if (!optionCount) { + return; + } + const nextIndex = + (this.clampedActiveOptionIndex() + step + optionCount) % optionCount; + this.setActiveOptionIndex(nextIndex); + } + + private setActiveOptionIndex(optionIndex: number): void { + if (optionIndex < 0) { + return; + } + this.activeOptionIndex.set(optionIndex); + this.scrollActiveOptionIntoView(optionIndex); + } + + private clampedActiveOptionIndex(): number { + const optionCount = this.visibleOptions().length; + if (!optionCount) { + return -1; + } + return Math.min(this.activeOptionIndex(), optionCount - 1); + } + + private indexOfSelectedOption(): number { + const selectedOption = this.selectedOption(); + return selectedOption + ? this.visibleOptions().findIndex( + (option) => option.timezoneId === selectedOption.timezoneId, + ) + : -1; + } + + // Every option stays in the DOM while the panel is open, so the row can be + // looked up and scrolled without waiting for another render pass. + private scrollActiveOptionIntoView(optionIndex: number): void { + const optionElement = ( + this.elementRef.nativeElement as HTMLElement + ).querySelector(`#${this.buildOptionElementId(optionIndex)}`); + optionElement?.scrollIntoView({ block: 'nearest' }); } } diff --git a/src/app/shared/services/timezone-service/timezone.service.spec.ts b/src/app/shared/services/timezone-service/timezone.service.spec.ts new file mode 100644 index 000000000..9694e9db1 --- /dev/null +++ b/src/app/shared/services/timezone-service/timezone.service.spec.ts @@ -0,0 +1,344 @@ +import { TimezoneService } from './timezone.service'; + +describe('TimezoneService', () => { + let service: TimezoneService; + + beforeEach(() => { + service = new TimezoneService(); + }); + + describe('resolveTimezoneId', () => { + it('should return null for values that are not non-empty strings', () => { + expect(service.resolveTimezoneId(undefined)).toBeNull(); + expect(service.resolveTimezoneId(null)).toBeNull(); + expect(service.resolveTimezoneId('')).toBeNull(); + expect(service.resolveTimezoneId(' ')).toBeNull(); + expect(service.resolveTimezoneId(0)).toBeNull(); + expect(service.resolveTimezoneId(123)).toBeNull(); + expect(service.resolveTimezoneId({})).toBeNull(); + expect(service.resolveTimezoneId([])).toBeNull(); + expect(service.resolveTimezoneId(true)).toBeNull(); + }); + + it('should return null for strings that are not timezone identifiers', () => { + expect(service.resolveTimezoneId('Not/AZone')).toBeNull(); + expect(service.resolveTimezoneId('Europe')).toBeNull(); + expect(service.resolveTimezoneId('GMT+02:00')).toBeNull(); + }); + + it('should return the identifier for a supported zone', () => { + expect(service.resolveTimezoneId('Europe/Bucharest')).toEqual( + 'Europe/Bucharest', + ); + }); + + it('should trim surrounding whitespace', () => { + expect(service.resolveTimezoneId(' Europe/Berlin ')).toEqual( + 'Europe/Berlin', + ); + }); + + it('should canonicalize casing', () => { + expect(service.resolveTimezoneId('europe/berlin')).toEqual( + 'Europe/Berlin', + ); + }); + + it('should accept identifiers missing from the supported list', () => { + expect(service.resolveTimezoneId('UTC')).toEqual('UTC'); + expect(service.resolveTimezoneId('Etc/UTC')).toEqual('UTC'); + }); + }); + + describe('getGroupedOptions', () => { + it('should group zones by the leading segment of the identifier', () => { + const groups = service.getGroupedOptions(); + const europe = groups.find((group) => group.region === 'Europe'); + + expect(europe).toBeTruthy(); + expect( + europe.options.some( + (option) => option.timezoneId === 'Europe/Bucharest', + ), + ).toBeTrue(); + + expect( + europe.options.every((option) => + option.timezoneId.startsWith('Europe/'), + ), + ).toBeTrue(); + }); + + it('should sort regions and the options inside them', () => { + const groups = service.getGroupedOptions(); + const regions = groups.map((group) => group.region); + + expect(regions).toEqual([...regions].sort()); + groups.forEach((group) => { + const identifiers = group.options.map((option) => option.timezoneId); + + expect(identifiers).toEqual( + [...identifiers].sort((a, b) => a.localeCompare(b)), + ); + }); + }); + + it('should return the same memoized instance on repeated calls', () => { + expect(service.getGroupedOptions()).toBe(service.getGroupedOptions()); + }); + }); + + describe('getOption', () => { + it('should return null for an unusable value', () => { + expect(service.getOption(undefined)).toBeNull(); + expect(service.getOption('Not/AZone')).toBeNull(); + expect(service.getOption(42)).toBeNull(); + }); + + it('should describe a supported zone', () => { + const option = service.getOption('Europe/Bucharest'); + + expect(option.timezoneId).toEqual('Europe/Bucharest'); + expect(option.region).toEqual('Europe'); + expect(option.offsetLabel).toMatch(/^GMT[+-]\d{2}:\d{2}$/); + }); + + it('should build an option for a resolvable zone missing from the list', () => { + const option = service.getOption('UTC'); + + expect(option.timezoneId).toEqual('UTC'); + expect(option.offsetLabel).toEqual('GMT+00:00'); + }); + + it('should report a bare GMT zone as GMT+00:00', () => { + expect(service.getOption('Africa/Accra').offsetLabel).toEqual( + 'GMT+00:00', + ); + }); + + it('should name the country the zone belongs to', () => { + expect(service.getOption('Africa/Accra').countryName).toEqual('Ghana'); + expect(service.getOption('Europe/Bucharest').countryName).toEqual( + 'Romania', + ); + + expect(service.getOption('America/New_York').countryName).toEqual( + 'United States', + ); + }); + + it('should resolve a country for every supported zone', () => { + const withoutCountry = service + .getGroupedOptions() + .flatMap((group) => group.options) + .filter((option) => !option.countryName); + + expect(withoutCountry).toEqual([]); + }); + + it('should leave the country blank for a zone that belongs to none', () => { + expect(service.getOption('UTC').countryName).toEqual(''); + }); + + it('should cover the identifier, the display name, the country and the offset in the search text', () => { + const option = service.getOption('Europe/Bucharest'); + + expect(option.searchText).toContain('bucharest'); + expect(option.searchText).toContain('europe'); + expect(option.searchText).toContain('eastern european time'); + expect(option.searchText).toContain('romania'); + expect(option.searchText).toContain(option.offsetLabel.toLowerCase()); + }); + + it('should space out the separators so a city name is searchable', () => { + expect(service.getOption('America/New_York').searchText).toContain( + 'new york', + ); + }); + + it('should still build a search text when no display name is available', () => { + spyOn( + service as unknown as { extractTimezoneNamePart: () => string }, + 'extractTimezoneNamePart', + ).and.returnValue(''); + + expect( + service.getOption('America/Argentina/La_Rioja').searchText, + ).toContain('la rioja'); + }); + }); + + describe('getOffsetForWallClock', () => { + const noon = { month: 5, day: 12, hour: 12, minute: 45, second: 0 }; + + it('should return null for an unusable identifier', () => { + expect( + service.getOffsetForWallClock('Not/AZone', { ...noon, year: 1985 }), + ).toBeNull(); + + expect( + service.getOffsetForWallClock(undefined, { ...noon, year: 1985 }), + ).toBeNull(); + + expect( + service.getOffsetForWallClock(null, { ...noon, year: 1985 }), + ).toBeNull(); + }); + + it('should use the offset the zone was on at that date', () => { + expect( + service.getOffsetForWallClock('Europe/Bucharest', { + ...noon, + year: 1985, + }), + ).toEqual('+03:00'); + }); + + it('should follow daylight saving transitions', () => { + expect( + service.getOffsetForWallClock('Europe/Bucharest', { + year: 2026, + month: 1, + day: 15, + hour: 12, + minute: 0, + second: 0, + }), + ).toEqual('+02:00'); + + expect( + service.getOffsetForWallClock('Europe/Bucharest', { + year: 2026, + month: 7, + day: 15, + hour: 12, + minute: 0, + second: 0, + }), + ).toEqual('+03:00'); + }); + + it('should follow historical offset changes', () => { + expect( + service.getOffsetForWallClock('Asia/Kathmandu', { + ...noon, + year: 1985, + }), + ).toEqual('+05:30'); + + expect( + service.getOffsetForWallClock('Asia/Kathmandu', { + year: 2026, + month: 1, + day: 15, + hour: 12, + minute: 0, + second: 0, + }), + ).toEqual('+05:45'); + }); + + it('should handle half-hour and quarter-hour offsets', () => { + expect( + service.getOffsetForWallClock('Asia/Kolkata', { ...noon, year: 2026 }), + ).toEqual('+05:30'); + + expect( + service.getOffsetForWallClock('Australia/Eucla', { + ...noon, + year: 2026, + }), + ).toEqual('+08:45'); + }); + + it('should handle negative offsets', () => { + expect( + service.getOffsetForWallClock('America/New_York', { + year: 2026, + month: 1, + day: 15, + hour: 12, + minute: 0, + second: 0, + }), + ).toEqual('-05:00'); + }); + + it('should report zero offsets as +00:00', () => { + expect( + service.getOffsetForWallClock('Africa/Accra', { ...noon, year: 1985 }), + ).toEqual('+00:00'); + + expect( + service.getOffsetForWallClock('UTC', { ...noon, year: 2026 }), + ).toEqual('+00:00'); + }); + + it('should drop the seconds from pre-standard-time local mean offsets', () => { + // Two-digit years must not be shifted into the 1900s, and Africa/Accra + // reports GMT-00:16:08 that far back. + expect( + service.getOffsetForWallClock('Africa/Accra', { ...noon, year: 85 }), + ).toEqual('-00:16'); + }); + }); + + describe('getFirstTimezoneIdForOffset', () => { + const offsetLabelOf = (timezoneId: string): string => + service.getOption(timezoneId).offsetLabel; + + it('should return null for anything that is not an EDTF offset', () => { + expect(service.getFirstTimezoneIdForOffset(undefined)).toBeNull(); + expect(service.getFirstTimezoneIdForOffset(null)).toBeNull(); + expect(service.getFirstTimezoneIdForOffset('')).toBeNull(); + expect( + service.getFirstTimezoneIdForOffset('Europe/Bucharest'), + ).toBeNull(); + + expect(service.getFirstTimezoneIdForOffset('GMT+02:00')).toBeNull(); + expect(service.getFirstTimezoneIdForOffset('+2:00')).toBeNull(); + expect(service.getFirstTimezoneIdForOffset('+0200')).toBeNull(); + expect(service.getFirstTimezoneIdForOffset(120)).toBeNull(); + }); + + it('should return a zone sitting on the offset asked for', () => { + for (const offset of ['+00:00', '+01:00', '+02:00', '+05:30', '-05:00']) { + const timezoneId = service.getFirstTimezoneIdForOffset(offset); + + expect(timezoneId).toBeTruthy(); + expect(offsetLabelOf(timezoneId)).toEqual(`GMT${offset}`); + } + }); + + it('should return the first identifier in the grouped list', () => { + const timezoneId = service.getFirstTimezoneIdForOffset('+02:00'); + const firstMatch = service + .getGroupedOptions() + .flatMap((group) => group.options) + .find((option) => option.offsetLabel === 'GMT+02:00'); + + expect(timezoneId).toEqual(firstMatch.timezoneId); + }); + + it('should infer the same zone every time for the same offset', () => { + expect(service.getFirstTimezoneIdForOffset('-05:00')).toEqual( + service.getFirstTimezoneIdForOffset('-05:00'), + ); + }); + + it('should return null for an offset no zone sits on today', () => { + expect(service.getFirstTimezoneIdForOffset('-12:00')).toBeNull(); + expect(service.getFirstTimezoneIdForOffset('+13:45')).toBeNull(); + }); + }); + + describe('getBrowserTimezoneId', () => { + it('should return a resolvable identifier', () => { + const browserTimezoneId = service.getBrowserTimezoneId(); + + expect(service.resolveTimezoneId(browserTimezoneId)).toEqual( + browserTimezoneId, + ); + }); + }); +}); diff --git a/src/app/shared/services/timezone-service/timezone.service.ts b/src/app/shared/services/timezone-service/timezone.service.ts new file mode 100644 index 000000000..76bb36dec --- /dev/null +++ b/src/app/shared/services/timezone-service/timezone.service.ts @@ -0,0 +1,386 @@ +import { Injectable } from '@angular/core'; + +export interface TimezoneOption { + /** The tz database identifier, e.g. 'Europe/Bucharest'. Shown as the label + * and stored on the record, so what is picked is what is persisted. */ + timezoneId: string; + offsetLabel: string; + region: string; + /** Empty when the engine cannot map zones to countries, or for zones that + * belong to no country such as UTC. */ + countryName: string; + /** Also covers the zone's display name and country, so 'eastern european' + * and 'romania' both find Europe/Bucharest. */ + searchText: string; +} + +export interface TimezoneGroup { + region: string; + options: TimezoneOption[]; +} + +export interface WallClockDateTime { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +const FALLBACK_REGION = 'Other'; +const MILLISECONDS_PER_MINUTE = 60000; +const MINUTES_PER_HOUR = 60; +const OFFSET_PATTERN = /([+-])(\d{1,2})(?::(\d{2}))?(?::\d{2})?/; +// The exact shape an EDTF datetime carries, so a stray string cannot be +// mistaken for an offset by the looser pattern above. +const EDTF_OFFSET_PATTERN = /^[+-]\d{2}:\d{2}$/; +const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + +// ISO 3166-1 alpha-2 is a closed two-letter space, and there is no API that +// enumerates it, so every pair is offered to Intl and the misses discarded. +const buildCandidateRegionCodes = (): string[] => + LETTERS.flatMap((first) => LETTERS.map((second) => `${first}${second}`)); + +@Injectable({ + providedIn: 'root', +}) +export class TimezoneService { + private groupedOptions: TimezoneGroup[] | null = null; + private optionsByTimezoneId: Map | null = null; + private countryNameByTimezoneId: Map | null = null; + + getGroupedOptions(): TimezoneGroup[] { + this.buildOptionsOnce(); + return this.groupedOptions; + } + + /** + * Accepts anything the backend might send and returns a timezone identifier + * the Intl APIs will accept, or null. Intl also canonicalizes casing and + * legacy aliases, so 'europe/berlin' resolves to 'Europe/Berlin' and + * 'US/Eastern' to 'America/New_York'. + */ + resolveTimezoneId(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) { + return null; + } + try { + return new Intl.DateTimeFormat('en-US', { + timeZone: value.trim(), + }).resolvedOptions().timeZone; + } catch { + return null; + } + } + + /** + * Intl.supportedValuesOf omits identifiers it still accepts elsewhere (UTC, + * Etc/UTC, GMT), so a resolvable identifier missing from the list gets an + * option built on the spot rather than being treated as unselectable. + */ + getOption(value: unknown): TimezoneOption | null { + const timezoneId = this.resolveTimezoneId(value); + if (!timezoneId) { + return null; + } + + this.buildOptionsOnce(); + return ( + this.optionsByTimezoneId.get(timezoneId) ?? this.buildOption(timezoneId) + ); + } + + /** + * The first zone whose current offset matches the one given, or null when no + * zone sits there today. An offset belongs to many places at once, so this + * can only ever be a guess; taking the first identifier in the list makes it + * a stable one, so the same offset always infers the same zone. + */ + getFirstTimezoneIdForOffset(offset: unknown): string | null { + if (typeof offset !== 'string' || !EDTF_OFFSET_PATTERN.test(offset)) { + return null; + } + + const offsetMinutes = this.parseOffsetMinutes(offset); + if (offsetMinutes === null) { + return null; + } + const offsetLabel = `GMT${this.formatOffsetMinutes(offsetMinutes)}`; + + this.buildOptionsOnce(); + for (const group of this.groupedOptions) { + const match = group.options.find( + (option) => option.offsetLabel === offsetLabel, + ); + if (match) { + return match.timezoneId; + } + } + return null; + } + + getBrowserTimezoneId(): string | null { + try { + return this.resolveTimezoneId( + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + } catch { + return null; + } + } + + /** + * The UTC offset a zone was on at a given wall-clock reading, as the + * '+HH:MM' an EDTF datetime expects. Returns null when the identifier is + * unusable so callers can fall back to their previous behaviour. + */ + getOffsetForWallClock( + value: unknown, + wallClock: WallClockDateTime, + ): string | null { + const timezoneId = this.resolveTimezoneId(value); + if (!timezoneId) { + return null; + } + + // A wall-clock reading has no offset yet, so read the offset as if it were + // UTC, shift by that much, then read again at the corrected instant. + const wallClockAsUtcMs = this.toUtcMilliseconds(wallClock); + const approximateOffsetMinutes = this.getOffsetMinutesAtInstant( + timezoneId, + new Date(wallClockAsUtcMs), + ); + if (approximateOffsetMinutes === null) { + return null; + } + + const offsetMinutes = this.getOffsetMinutesAtInstant( + timezoneId, + new Date( + wallClockAsUtcMs - approximateOffsetMinutes * MILLISECONDS_PER_MINUTE, + ), + ); + return offsetMinutes === null + ? null + : this.formatOffsetMinutes(offsetMinutes); + } + + private buildOptionsOnce(): void { + if (this.groupedOptions && this.optionsByTimezoneId) { + return; + } + + const optionsByRegion = new Map(); + this.optionsByTimezoneId = new Map(); + + for (const timezoneId of this.getSupportedTimezoneIds()) { + const option = this.buildOption(timezoneId); + this.optionsByTimezoneId.set(timezoneId, option); + + const regionOptions = optionsByRegion.get(option.region) ?? []; + regionOptions.push(option); + optionsByRegion.set(option.region, regionOptions); + } + + this.groupedOptions = [...optionsByRegion.entries()] + .map(([region, options]) => ({ + region, + options: options.sort((left, right) => + left.timezoneId.localeCompare(right.timezoneId), + ), + })) + .sort((left, right) => left.region.localeCompare(right.region)); + } + + private getSupportedTimezoneIds(): string[] { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + const browserTimezoneId = this.getBrowserTimezoneId(); + return browserTimezoneId ? [browserTimezoneId] : []; + } + } + + private buildOption(timezoneId: string): TimezoneOption { + const today = new Date(); + const displayName = + this.extractTimezoneNamePart(timezoneId, 'longGeneric', today) || + this.extractTimezoneNamePart(timezoneId, 'long', today); + const offsetLabel = this.buildOffsetLabel(timezoneId, today); + const region = timezoneId.includes('/') + ? timezoneId.split('/')[0].replace(/_/g, ' ') + : FALLBACK_REGION; + const countryName = this.getCountryName(timezoneId); + + return { + timezoneId, + offsetLabel, + region, + countryName, + // Separators are spaced out so 'new york' matches America/New_York. + searchText: [ + timezoneId.replace(/[/_]/g, ' '), + displayName, + countryName, + offsetLabel, + ] + .join(' ') + .toLowerCase(), + }; + } + + private getCountryName(timezoneId: string): string { + this.buildCountryNamesOnce(); + return this.countryNameByTimezoneId.get(timezoneId) ?? ''; + } + + /** + * The tz database knows which country each zone belongs to, and the browser + * exposes it the other way round: per region code, which zones it holds. + * Inverting that over every ISO region gives the zone's country. Engines + * without the API simply leave every country name blank. + */ + private buildCountryNamesOnce(): void { + if (this.countryNameByTimezoneId) { + return; + } + this.countryNameByTimezoneId = new Map(); + + const regionNames = this.createRegionDisplayNames(); + if (!regionNames) { + return; + } + + for (const regionCode of buildCandidateRegionCodes()) { + const countryName = this.resolveCountryName(regionNames, regionCode); + if (!countryName) { + continue; + } + for (const timezoneId of this.getTimezoneIdsForRegion(regionCode)) { + if (!this.countryNameByTimezoneId.has(timezoneId)) { + this.countryNameByTimezoneId.set(timezoneId, countryName); + } + } + } + } + + private createRegionDisplayNames(): Intl.DisplayNames | null { + try { + return new Intl.DisplayNames(['en'], { type: 'region' }); + } catch { + return null; + } + } + + // A code with no translation is not a real region; Intl echoes it back. + private resolveCountryName( + regionNames: Intl.DisplayNames, + regionCode: string, + ): string { + try { + const countryName = regionNames.of(regionCode); + return countryName && countryName !== regionCode ? countryName : ''; + } catch { + return ''; + } + } + + private getTimezoneIdsForRegion(regionCode: string): string[] { + try { + // getTimeZones() is the current form; older engines expose the same + // data as a timeZones accessor, and some expose neither. + const locale = new Intl.Locale(`und-${regionCode}`) as Intl.Locale & { + getTimeZones?: () => string[] | undefined; + timeZones?: string[]; + }; + return locale.getTimeZones?.() ?? locale.timeZones ?? []; + } catch { + return []; + } + } + + private buildOffsetLabel(timezoneId: string, instant: Date): string { + const offsetMinutes = this.getOffsetMinutesAtInstant(timezoneId, instant); + return offsetMinutes === null + ? '' + : `GMT${this.formatOffsetMinutes(offsetMinutes)}`; + } + + private getOffsetMinutesAtInstant( + timezoneId: string, + instant: Date, + ): number | null { + const rawOffsetName = this.extractTimezoneNamePart( + timezoneId, + 'longOffset', + instant, + ); + if (!rawOffsetName) { + return null; + } + // Zones sitting at zero report a bare 'GMT' rather than an offset. + if (rawOffsetName === 'GMT') { + return 0; + } + return this.parseOffsetMinutes(rawOffsetName); + } + + /** + * Dates predating standard time report local mean time down to the second + * (Africa/Accra reads GMT-00:16:08 in the year 85); EDTF offsets only carry + * hours and minutes, so any seconds are dropped. + */ + private parseOffsetMinutes(timezoneName: string): number | null { + const offsetMatch = OFFSET_PATTERN.exec(timezoneName); + if (!offsetMatch) { + return null; + } + const [, sign, hours, minutes] = offsetMatch; + const totalMinutes = + Number(hours) * MINUTES_PER_HOUR + Number(minutes ?? 0); + return sign === '-' ? -totalMinutes : totalMinutes; + } + + private extractTimezoneNamePart( + timezoneId: string, + timeZoneName: 'longOffset' | 'longGeneric' | 'long', + instant: Date, + ): string { + try { + return ( + new Intl.DateTimeFormat('en-US', { timeZone: timezoneId, timeZoneName }) + .formatToParts(instant) + .find((part) => part.type === 'timeZoneName')?.value ?? '' + ); + } catch { + return ''; + } + } + + private toUtcMilliseconds(wallClock: WallClockDateTime): number { + // Date.UTC maps years 0-99 into the 1900s, so set the year explicitly. + const instant = new Date( + Date.UTC( + 2000, + wallClock.month - 1, + wallClock.day, + wallClock.hour, + wallClock.minute, + wallClock.second, + ), + ); + instant.setUTCFullYear(wallClock.year); + return instant.getTime(); + } + + private formatOffsetMinutes(offsetMinutes: number): string { + const sign = offsetMinutes < 0 ? '-' : '+'; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = String( + Math.floor(absoluteMinutes / MINUTES_PER_HOUR), + ).padStart(2, '0'); + const minutes = String(absoluteMinutes % MINUTES_PER_HOUR).padStart(2, '0'); + return `${sign}${hours}:${minutes}`; + } +} From 019bf6da5076ce1fe089f40c07e32751bc331404 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sat, 12 Sep 2026 16:11:03 +0300 Subject: [PATCH 2/4] Infer of complete the offset for the edtf date using the timezone property. Up until now we were just adding the current timezone of the browser, which made times and even edge case dates inaccurate. With adding the timezone field, now not only we have the correct offset, but the exact tz identifier that places the date and time in a certain region. From a technical perspective, we are using the tz identifier to infer and set the offset of the date and time, only in special cases where we do not have a stored timezone, we add an approximate tz identifier based on the offset we have already saved. Issue: PER-10623 --- .../edit-date-time-modal.component.html | 18 + .../edit-date-time-modal.component.scss | 4 + .../edit-date-time-modal.component.spec.ts | 149 ++++++ .../edit-date-time-modal.component.ts | 44 +- .../sidebar-date-picker.component.html | 7 + .../sidebar-date-picker.component.scss | 4 + .../sidebar-date-picker.component.spec.ts | 114 ++++- .../sidebar-date-picker.component.ts | 74 ++- .../edtf-service/edtf.service.spec.ts | 424 ++++++++++++++---- .../services/edtf-service/edtf.service.ts | 165 ++++--- 10 files changed, 851 insertions(+), 152 deletions(-) diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html index ebc8c850d..2837975ce 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html @@ -66,6 +66,15 @@

    Edit date and time

    /> + +