diff --git a/src/app/core/services/edit/edit.service.spec.ts b/src/app/core/services/edit/edit.service.spec.ts index 4960a0bdf..c5e52e473 100644 --- a/src/app/core/services/edit/edit.service.spec.ts +++ b/src/app/core/services/edit/edit.service.spec.ts @@ -520,6 +520,64 @@ describe('EditService', () => { ).toBeResolved(); }); + it('should send several properties in one update and revert them together on failure', async () => { + const messageService = TestBed.inject(MessageService); + spyOn(messageService, 'showError'); + spyOn(console, 'error'); + + const record = new RecordVO({ + recordId: 1, + displayTime: 'original-value', + timezone: 'America/Chicago', + }); + + (apiService.record.updateStelaRecord as jasmine.Spy).and.returnValue( + Promise.reject({}), + ); + (apiService.record.update as jasmine.Spy).and.returnValue( + Promise.resolve([]), + ); + + await service.saveItemVoProperties( + record, + { displayTime: 'new-value', timezone: 'Europe/Bucharest' }, + ['displayTime'], + ); + + expect(record.displayTime).toBe('original-value'); + expect(record.timezone).toBe('America/Chicago'); + }); + + it('should apply several properties before the update call', async () => { + const record = new RecordVO({ recordId: 1 }); + let timezoneWhenSent: string; + + (apiService.record.updateStelaRecord as jasmine.Spy).and.callFake( + async (sentRecord: RecordVO) => { + timezoneWhenSent = sentRecord.timezone; + return {}; + }, + ); + (apiService.record.get as jasmine.Spy).and.returnValue( + Promise.resolve({ getRecordVOs: () => [] }), + ); + + await service.saveItemVoProperties( + record, + { displayTime: '1985-05-20', timezone: 'Europe/Bucharest' }, + ['displayTime'], + ); + + expect(timezoneWhenSent).toBe('Europe/Bucharest'); + expect(record.displayTime).toBe('1985-05-20'); + }); + + it('should do nothing when there is no item to save', async () => { + await expectAsync( + service.saveItemVoProperties(null, { displayTime: 'x' }, ['displayTime']), + ).toBeResolved(); + }); + it('should revert property and show a translatable generic error when updateStelaRecord fails', async () => { const messageService = TestBed.inject(MessageService); spyOn(messageService, 'showError'); diff --git a/src/app/core/services/edit/edit.service.ts b/src/app/core/services/edit/edit.service.ts index ff2757923..a346ee09a 100644 --- a/src/app/core/services/edit/edit.service.ts +++ b/src/app/core/services/edit/edit.service.ts @@ -352,30 +352,47 @@ export class EditService { property: KeysOfType, value: string, ) { - if (item) { - const originalValue = item[property]; - const newData: Partial = {}; - newData[property] = value; - try { - item.update(newData); - await this.updateItems([item], [property]); - } catch (err) { - const revertData: Partial = {}; - revertData[property] = originalValue; - item.update(revertData); - - if (err instanceof FolderResponse || err instanceof RecordResponse) { - this.message.showError({ - message: err.getMessage(), - translate: true, - }); - } else { - console.error('Failed to save item property', err); - this.message.showError({ - message: 'error.generic.update_fail', - translate: true, - }); - } + const newData: Partial = {}; + newData[property] = value; + await this.saveItemVoProperties(item, newData, [property]); + } + + /** + * Applies several properties in one optimistic update so fields that belong + * together — a date and the timezone it was recorded in — reach the backend + * in a single request and revert together when it fails. + */ + public async saveItemVoProperties( + item: ItemVO, + changes: Partial, + whitelist: (keyof ItemVO)[], + ) { + if (!item) { + return; + } + + const originalValues: Record = {}; + Object.keys(changes).forEach((key) => { + originalValues[key] = item[key]; + }); + + try { + item.update(changes); + await this.updateItems([item], whitelist); + } catch (err) { + item.update(originalValues as Partial); + + if (err instanceof FolderResponse || err instanceof RecordResponse) { + this.message.showError({ + message: err.getMessage(), + translate: true, + }); + } else { + console.error('Failed to save item property', err); + this.message.showError({ + message: 'error.generic.update_fail', + translate: true, + }); } } } @@ -432,6 +449,10 @@ export class EditService { newData.displayTime = updatedItem.displayTime; } + if (updatedItem.timezone) { + newData.timezone = updatedItem.timezone; + } + if (updatedItem.displayDT) { newData.displayDT = updatedItem.displayDT; } @@ -464,6 +485,10 @@ export class EditService { newData.displayTime = res.displayTime; } + if (res.timezone) { + newData.timezone = res.timezone; + } + if (res.displayDT) { newData.displayDT = res.displayDT; } 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

/> + + + @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/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index c8a8927ed..25fddee56 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -273,6 +273,67 @@ describe('Folder repo', () => { }); }); + it('should send the timezone as location metadata when the folder has one', async () => { + const folderVO = new FolderVO({ + folderId: 123, + displayTime: '1985-05-20T00:00:00Z', + timezone: 'Europe/Bucharest', + }); + + httpV2Spy.patch.and.returnValue(of([mockStelaFolder])); + + await folderRepo.updateStelaFolder(folderVO); + + expect(httpV2Spy.patch).toHaveBeenCalledWith('v2/folder/123', { + displayTime: '1985-05-20T00:00:00Z', + location: { timezone: 'Europe/Bucharest' }, + }); + }); + + it('should send a null timezone only when it is explicitly cleared', async () => { + const folderVO = new FolderVO({ + folderId: 123, + displayTime: null, + timezone: null, + }); + + httpV2Spy.patch.and.returnValue(of([mockStelaFolder])); + + await folderRepo.updateStelaFolder(folderVO); + + expect(httpV2Spy.patch).toHaveBeenCalledWith('v2/folder/123', { + displayTime: null, + location: { timezone: null }, + }); + }); + + it('should lift the timezone off the location and onto the folder', async () => { + const folderVO = new FolderVO({ folderId: 123 }); + + httpV2Spy.patch.and.returnValue( + of([ + { + ...mockStelaFolder, + location: { id: '1', timezone: 'Europe/Bucharest' }, + }, + ]), + ); + + const result = await folderRepo.updateStelaFolder(folderVO); + + expect(result.Results[0][0].FolderVO.timezone).toBe('Europe/Bucharest'); + }); + + it('should null the timezone when the folder has none', async () => { + const folderVO = new FolderVO({ folderId: 123 }); + + httpV2Spy.patch.and.returnValue(of([mockStelaFolder])); + + const result = await folderRepo.updateStelaFolder(folderVO); + + expect(result.Results[0][0].FolderVO.timezone).toBeNull(); + }); + it('should convert response StelaFolder to FolderVO', async () => { const folderVO = new FolderVO({ folderId: 123, diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 148fb30c8..40cc3e767 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -12,6 +12,7 @@ import { convertStelaRecordToRecordVO, convertStelaSharetoShareVO, convertStelaTagToTagVO, + buildTimezonePatch, StelaLocation, StelaShare, StelaTag, @@ -141,6 +142,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { displayDT: stelaFolder.displayTimestamp, displayEndDT: stelaFolder.displayEndTimestamp, displayTime: stelaFolder.displayTime, + timezone: stelaFolder.location?.timezone ?? null, derivedDT: stelaFolder.displayTimestamp, derivedEndDT: stelaFolder.displayEndTimestamp, // Stela names these createdAt / updatedAt. Records already map them; folders @@ -283,6 +285,7 @@ export class FolderRepo extends BaseRepo { public async updateStelaFolder(folderVO: FolderVO): Promise { const payload = { displayTime: folderVO.displayTime, + ...buildTimezonePatch(folderVO.timezone), }; const response = await firstValueFrom( diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index 4cacd2974..15ca79a4f 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -236,6 +236,15 @@ describe('RecordRepo', () => { ).toBeNull(); }); + it('keeps the timezone out of the location VO', () => { + const result = convertStelaLocationToLocnVOData({ + id: '42', + timezone: 'Europe/Bucharest', + } as StelaLocation); + + expect('timezone' in result).toBeFalse(); + }); + it('parses the id and remaps state/precision onto the LocnVO shape', () => { const stelaLocation: StelaLocation = { id: '42', @@ -370,6 +379,57 @@ describe('RecordRepo', () => { expect(result).toBeInstanceOf(RecordResponse); }); + it('should send the timezone as location metadata when the record has one', async () => { + const recordVO = new RecordVO({ + recordId: 42, + displayTime: '1985-05-20T00:00:00.000Z', + timezone: 'Europe/Bucharest', + }); + + httpV2PatchSpy.and.returnValue(of([fakeStelaRecord])); + + await repo.updateStelaRecord(recordVO); + + expect(httpV2PatchSpy).toHaveBeenCalledWith('v2/records/42', { + displayTime: '1985-05-20T00:00:00.000Z', + location: { timezone: 'Europe/Bucharest' }, + }); + }); + + it('should send a null timezone only when it is explicitly cleared', async () => { + const recordVO = new RecordVO({ + recordId: 42, + displayTime: null, + timezone: null, + }); + + httpV2PatchSpy.and.returnValue(of([fakeStelaRecord])); + + await repo.updateStelaRecord(recordVO); + + expect(httpV2PatchSpy).toHaveBeenCalledWith('v2/records/42', { + displayTime: null, + location: { timezone: null }, + }); + }); + + it('should omit location entirely when the timezone is untouched', async () => { + // Stela rejects an empty location object, so an untouched timezone + // must not send the key at all. + const recordVO = new RecordVO({ + recordId: 42, + displayTime: '1985-05-20T00:00:00.000Z', + }); + + httpV2PatchSpy.and.returnValue(of([fakeStelaRecord])); + + await repo.updateStelaRecord(recordVO); + + expect(httpV2PatchSpy).toHaveBeenCalledWith('v2/records/42', { + displayTime: '1985-05-20T00:00:00.000Z', + }); + }); + it('should look up recordId by archiveNbr when recordId is not available', async () => { const recordVO = new RecordVO({ archiveNbr: 'archive-100', @@ -448,6 +508,36 @@ describe('RecordRepo', () => { expect(record.displayTime).toBeUndefined(); }); + it('should lift the timezone off the location and onto the record', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + location: { id: '42', timezone: 'Europe/Bucharest' }, + } as any); + + expect(record.timezone).toBe('Europe/Bucharest'); + }); + + it('should keep the timezone even when there is no location row', () => { + // Stela reads the timezone off the record row but nests it in the + // location object, so it arrives with a null location id. + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + location: { id: null, timezone: 'Europe/Bucharest' }, + } as any); + + expect(record.timezone).toBe('Europe/Bucharest'); + expect(record.LocnVO).toBeNull(); + }); + + it('should null the timezone when the record has none', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + location: { id: '42' }, + } as any); + + expect(record.timezone).toBeNull(); + }); + it('maps thumbnailUrls to all thumb fields', () => { const record = convertStelaRecordToRecordVO({ ...baseStelaRecord, diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index 581eac12e..63285508f 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -86,6 +86,10 @@ export interface StelaLocation { longitude?: number | null; altitudeMeters?: number | null; precision?: LocationPrecision | null; + // Stela treats the timezone as location metadata but stores it on the record + // or folder row, so it arrives here even when there is no location to speak + // of. It is a free-text column, so nothing guarantees a usable value. + timezone?: string | null; // Legacy columns still returned by stela for backwards compatibility. Used // to shim locations that predate (or were geocoded without) the new fields. streetNumber?: string | null; @@ -174,13 +178,25 @@ export const convertStelaSharetoShareVO = (stelaShare: StelaShare): ShareVO => }, }); +/** + * Stela takes the timezone as location metadata. Its location schema rejects an + * empty object and may not be sent alongside locationId, so an untouched + * timezone omits the key entirely and only an explicit clear sends null. + */ +export const buildTimezonePatch = ( + timezone: string | null | undefined, +): { location?: { timezone: string | null } } => + timezone === undefined ? {} : { location: { timezone } }; + export const convertStelaLocationToLocnVOData = ( stelaLocation: StelaLocation | null | undefined, ): LocnVOData | null => { if (!stelaLocation?.id) { return null; } - const { state, precision, ...rest } = stelaLocation; + // The timezone rides along on the location but belongs to the record or + // folder, so it is lifted onto the item rather than into the location VO. + const { state, precision, timezone, ...rest } = stelaLocation; // Legacy shim: locations geocoded before the IPTC fields existed — or by // backend paths (e.g. EXIF geocoding on upload) that still only write the // legacy columns — arrive without name/sublocation/city. Fall back to the @@ -222,6 +238,7 @@ export const convertStelaRecordToRecordVO = ( accessRole: getAccessRoleFromArchiveMembershipRole(stelaRecord.accessRole), displayDT: stelaRecord.displayDate, displayTime: stelaRecord.displayTime, + timezone: stelaRecord.location?.timezone ?? null, folder_linkId: Number.parseInt(stelaRecord.folderLinkId, 10), folder_linkType: stelaRecord.folderLinkType, LocnVO: convertStelaLocationToLocnVOData(stelaRecord.location), @@ -521,11 +538,12 @@ export class RecordRepo extends BaseRepo { recordVO.recordId ?? (await this.getRecordIdByArchiveNbr(recordVO.archiveNbr)); - // For now we only send displayTime. This will evolve until we can - // update the whole record using this method. + // For now we only send displayTime and the timezone. This will evolve + // until we can update the whole record using this method. const stelaRecord = await firstValueFrom( this.httpV2.patch(`v2/records/${recordId}`, { displayTime: recordVO.displayTime, + ...buildTimezonePatch(recordVO.timezone), }), ); 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 bbc57b9b2..be3f88085 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -1,3 +1,5 @@ +import { TestBed } from '@angular/core/testing'; +import { TimezoneService } from '@shared/services/timezone-service/timezone.service'; import { EdtfService, DateTimeModel, @@ -6,22 +8,14 @@ import { INVALID_DAY_FOR_MONTH_ERROR, } 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; + let timezoneService: TimezoneService; beforeEach(() => { - service = new EdtfService(); + TestBed.configureTestingModule({}); + service = TestBed.inject(EdtfService); + timezoneService = TestBed.inject(TimezoneService); }); describe('toDateTimeModel', () => { @@ -188,11 +182,17 @@ describe('EdtfService', () => { expect(result.time.timezoneOffset).toBe('+05:30'); }); - it('should not set a timezone offset for unmarked or Z-marked times', () => { + it('should not set a timezone offset for an unmarked time', () => { const unmarked = service.toDateTimeModel('1985-05-20T14:30:45'); - const utcMarked = service.toDateTimeModel('1985-05-20T14:30:45Z'); expect(unmarked.time.timezoneOffset).toBeUndefined(); + }); + + it('should not set a timezone offset for a Z-marked time', () => { + // 'Z' names no place, so there is nothing in it to recover a zone + // from and nothing is invented from the reader's own machine. + const utcMarked = service.toDateTimeModel('1985-05-20T14:30:45Z'); + expect(utcMarked.time.timezoneOffset).toBeUndefined(); }); @@ -673,7 +673,7 @@ describe('EdtfService', () => { expect(result).toBe('1985-05-20T14:30:45+05:30'); }); - it('should stamp the local timezone offset when the model has none', () => { + it('should write no offset at all when the model has none', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, time: { @@ -686,7 +686,7 @@ describe('EdtfService', () => { const result = service.toEdtfDate(model); - expect(result).toBe(`1985-05-20T14:30:45${localTimezoneOffset()}`); + expect(result).toBe('1985-05-20T14:30:45'); }); }); @@ -1016,71 +1016,6 @@ describe('EdtfService', () => { }); }); - describe('browserTimezoneAbbreviation', () => { - const stubTimezoneName = (timezoneName: string): void => { - spyOn(Intl, 'DateTimeFormat').and.returnValue({ - formatToParts: () => [{ type: 'timeZoneName', value: timezoneName }], - } as unknown as Intl.DateTimeFormat); - }; - - const sampleDate = { year: '1985', month: '05', day: '20' }; - const sampleTime = { - hours: '02', - minutes: '30', - seconds: '00', - format: 'pm' as const, - }; - - it('should return empty string when time has no hours', () => { - const result = service.browserTimezoneAbbreviation(sampleDate, { - hours: '', - format: 'am', - }); - - expect(result).toBe(''); - }); - - it('should keep named abbreviations unchanged', () => { - stubTimezoneName('EDT'); - - expect(service.browserTimezoneAbbreviation(sampleDate, sampleTime)).toBe( - 'EDT', - ); - }); - - it('should pad a whole-hour offset to +/-HH:MM', () => { - stubTimezoneName('GMT+3'); - - expect(service.browserTimezoneAbbreviation(sampleDate, sampleTime)).toBe( - 'GMT+03:00', - ); - }); - - it('should pad a half-hour positive offset to +/-HH:MM', () => { - stubTimezoneName('GMT+5:30'); - - expect(service.browserTimezoneAbbreviation(sampleDate, sampleTime)).toBe( - 'GMT+05:30', - ); - }); - - it('should pad a negative offset to +/-HH:MM', () => { - stubTimezoneName('GMT-9:30'); - - expect(service.browserTimezoneAbbreviation(sampleDate, sampleTime)).toBe( - 'GMT-09:30', - ); - }); - - it('should leave an already-normalized offset unchanged', () => { - stubTimezoneName('GMT+03:00'); - - expect(service.browserTimezoneAbbreviation(sampleDate, sampleTime)).toBe( - 'GMT+03:00', - ); - }); - }); - describe('parseTimeAs24Hour', () => { it('should convert PM time to 24-hour format', () => { const result = service.parseTimeAs24Hour({ @@ -1592,20 +1527,20 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); - it('should stamp the local offset on a date-time without timezone marker', () => { + it('should roundtrip a date-time without a timezone marker unchanged', () => { const model = service.toDateTimeModel('1985-05-20T23:23:23'); const result = service.toEdtfDate(model); - expect(result).toBe(`1985-05-20T23:23:23${localTimezoneOffset()}`); + expect(result).toBe('1985-05-20T23:23:23'); }); - it('should replace a fabricated Z marker with the local offset', () => { + it('should drop a fabricated Z marker rather than inventing an 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()}`); + expect(result).toBe('1985-05-20T23:23:23'); }); it('should roundtrip partial year (198X)', () => { @@ -1774,4 +1709,323 @@ describe('EdtfService', () => { ).toBe('May 1985'); }); }); + + describe('clearing the timezone (PER-10623 scenario)', () => { + it('should not infer the zone back after a clear round trip', () => { + const saved = service.toEdtfDate({ + date: { year: '2026', month: '07', day: '15' }, + time: { + hours: '12', + minutes: '45', + seconds: '00', + format: 'pm', + timezoneId: 'Europe/Bucharest', + }, + }); + + expect(saved).toBe('2026-07-15T12:45:00+03:00'); + + const reopened = service.withTimezone( + service.toDateTimeModel(saved), + 'Europe/Bucharest', + ); + + expect(reopened.time.timezoneId).toBe('Europe/Bucharest'); + + // What the picker does on clear: the offset goes with the zone. + const cleared = { + ...reopened, + time: { + ...reopened.time, + timezoneId: undefined, + timezoneOffset: undefined, + }, + }; + const clearedEdtf = service.toEdtfDate(cleared); + + expect(clearedEdtf).toBe('2026-07-15T12:45:00'); + expect(service.getPersistableTimezoneId(cleared)).toBeNull(); + + const reloaded = service.withTimezone( + service.toDateTimeModel(clearedEdtf), + null, + ); + + expect(reloaded.time.timezoneId).toBeUndefined(); + }); + }); + + describe('withTimezone', () => { + const parsedWithOffset = (): DateTimeModel => + service.toDateTimeModel('1985-05-12T12:45:00+02:00'); + + it('should stamp the identifier the item stores', () => { + const model = service.withTimezone( + parsedWithOffset(), + 'Europe/Bucharest', + ); + + expect(model.time.timezoneId).toEqual('Europe/Bucharest'); + }); + + it('should infer a zone from the offset when the item stores none', () => { + const model = service.withTimezone(parsedWithOffset(), null); + + expect(model.time.timezoneId).toEqual( + timezoneService.getFirstTimezoneIdForOffset('+02:00'), + ); + }); + + it('should prefer the stored zone over the one the offset implies', () => { + const model = service.withTimezone( + parsedWithOffset(), + 'Europe/Bucharest', + ); + + expect(model.time.timezoneId).toEqual('Europe/Bucharest'); + }); + + it('should infer when the stored value is unusable', () => { + for (const stored of ['', ' ', 'Not/AZone', 42, {}]) { + expect( + service.withTimezone(parsedWithOffset(), stored).time.timezoneId, + ).toEqual(timezoneService.getFirstTimezoneIdForOffset('+02:00')); + } + }); + + it('should infer from the end side when only it carries an offset', () => { + const model = service.toDateTimeModel('1985/2026-01-15T10:00:00-05:00'); + + expect(service.withTimezone(model, null).time.timezoneId).toEqual( + timezoneService.getFirstTimezoneIdForOffset('-05:00'), + ); + }); + + it('should apply the inferred zone to both sides of a range', () => { + const model = service.withTimezone( + service.toDateTimeModel( + '1985-05-12T12:45:00+02:00/2026-01-15T10:00:00+02:00', + ), + null, + ); + + expect(model.endTime.timezoneId).toEqual(model.time.timezoneId); + }); + + it('should leave a date with no time without a zone', () => { + const model = service.withTimezone( + service.toDateTimeModel('1985-05-12'), + null, + ); + + expect(model.time.timezoneId).toBeUndefined(); + }); + + it('should leave an unmarked time without a zone', () => { + const model = service.withTimezone( + service.toDateTimeModel('1985-05-12T12:45:00'), + null, + ); + + expect(model.time.timezoneId).toBeUndefined(); + }); + + it('should leave a Z-marked time without a zone', () => { + const model = service.withTimezone( + service.toDateTimeModel('1985-05-12T12:45:00Z'), + null, + ); + + expect(model.time.timezoneId).toBeUndefined(); + }); + + it('should still prefer a stored zone over a Z-marked time', () => { + const model = service.withTimezone( + service.toDateTimeModel('1985-05-12T12:45:00Z'), + 'Europe/Bucharest', + ); + + expect(model.time.timezoneId).toEqual('Europe/Bucharest'); + }); + + it('should persist the inferred zone once the date is saved', () => { + const model = service.withTimezone(parsedWithOffset(), null); + + expect(service.getPersistableTimezoneId(model)).toEqual( + model.time.timezoneId, + ); + }); + + it('should still return null for an item with neither date nor zone', () => { + expect(service.withTimezone(null, null)).toBeNull(); + }); + }); + + describe('timezone-driven offsets', () => { + const dateTimeIn = ( + timezoneId: string | undefined, + overrides: Partial = {}, + ): DateTimeModel => ({ + date: { year: '1985', month: '05', day: '12' }, + time: { + hours: '12', + minutes: '45', + seconds: '00', + format: 'pm', + timezoneId, + }, + ...overrides, + }); + + it('should stamp the offset the chosen zone was on at that date', () => { + expect(service.toEdtfDate(dateTimeIn('Europe/Bucharest'))).toBe( + '1985-05-12T12:45:00+03:00', + ); + }); + + it('should follow daylight saving for the date being edited', () => { + const winter = dateTimeIn('Europe/Bucharest', { + date: { year: '2026', month: '01', day: '15' }, + }); + const summer = dateTimeIn('Europe/Bucharest', { + date: { year: '2026', month: '07', day: '15' }, + }); + + expect(service.toEdtfDate(winter)).toBe('2026-01-15T12:45:00+02:00'); + expect(service.toEdtfDate(summer)).toBe('2026-07-15T12:45:00+03:00'); + }); + + it('should follow historical offset changes', () => { + expect(service.toEdtfDate(dateTimeIn('Asia/Kathmandu'))).toBe( + '1985-05-12T12:45:00+05:30', + ); + }); + + it('should prefer the chosen zone over an offset carried in from parsing', () => { + const model = dateTimeIn('Europe/Bucharest'); + model.time.timezoneOffset = '-05:00'; + + expect(service.toEdtfDate(model)).toBe('1985-05-12T12:45:00+03:00'); + }); + + it('should fall back to the parsed offset when the zone is unusable', () => { + const model = dateTimeIn('Not/AZone'); + model.time.timezoneOffset = '-05:00'; + + expect(service.toEdtfDate(model)).toBe('1985-05-12T12:45:00-05:00'); + }); + + it('should write no offset when nothing else is known', () => { + // The editing machine's zone is a fact about the reader, never the + // item, so an unknown zone leaves the time unmarked instead. + expect(service.toEdtfDate(dateTimeIn(undefined))).toBe( + '1985-05-12T12:45:00', + ); + }); + + it('should drop the parsed offset once the zone is cleared', () => { + // Clearing the picker strips time.timezoneOffset, which is what stops + // the same zone being inferred straight back on the next read. + const model = dateTimeIn(undefined); + delete model.time.timezoneOffset; + + expect(service.toEdtfDate(model)).toBe('1985-05-12T12:45:00'); + expect(service.withTimezone(model, null).time.timezoneId).toBeUndefined(); + }); + + it('should apply the chosen zone to both sides of a range', () => { + const model = dateTimeIn('Europe/Bucharest', { + endDate: { year: '2026', month: '01', day: '15' }, + endTime: { + hours: '10', + minutes: '00', + seconds: '00', + format: 'am', + timezoneId: 'Europe/Bucharest', + }, + }); + + expect(service.toEdtfDate(model)).toBe( + '1985-05-12T12:45:00+03:00/2026-01-15T10:00:00+02:00', + ); + }); + + it('should ignore the zone for a date with no time', () => { + expect( + service.getPersistableTimezoneId({ + date: { year: '1985', month: '05', day: '12' }, + time: { format: 'am', timezoneId: 'Europe/Bucharest' }, + }), + ).toBeNull(); + }); + + it('should ignore the zone when the time cannot be read', () => { + expect( + service.getPersistableTimezoneId({ + date: { year: '1985', month: '05', day: '12' }, + time: { + hours: '99', + minutes: '00', + format: 'h24', + timezoneId: 'Europe/Bucharest', + }, + }), + ).toBeNull(); + }); + + it('should keep the zone when a readable time is present', () => { + expect( + service.getPersistableTimezoneId({ + date: { year: '1985', month: '05', day: '12' }, + time: { + hours: '12', + minutes: '45', + format: 'pm', + timezoneId: 'Europe/Bucharest', + }, + }), + ).toEqual('Europe/Bucharest'); + }); + + it('should keep the zone when only the end side carries a time', () => { + expect( + service.getPersistableTimezoneId({ + date: { year: '1985', month: '05', day: '12' }, + time: { format: 'am', timezoneId: 'Europe/Bucharest' }, + endDate: { year: '2026', month: '01', day: '15' }, + endTime: { + hours: '10', + minutes: '00', + format: 'am', + timezoneId: 'Europe/Bucharest', + }, + }), + ).toEqual('Europe/Bucharest'); + }); + + it('should return null when no zone was chosen at all', () => { + expect( + service.getPersistableTimezoneId({ + date: { year: '1985', month: '05', day: '12' }, + time: { hours: '12', minutes: '45', format: 'pm' }, + }), + ).toBeNull(); + }); + + it('should reject a time that cannot be read, so save stays disabled', () => { + expect(() => + service.toEdtfDate({ + date: { year: '1985', month: '05', day: '12' }, + time: { hours: '99', minutes: '00', format: 'h24' }, + }), + ).toThrow(); + }); + + it('should survive a parse and re-serialize roundtrip', () => { + const edtfString = service.toEdtfDate(dateTimeIn('Europe/Bucharest')); + const parsed = service.toDateTimeModel(edtfString); + + expect(service.toEdtfDate(parsed)).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 300743f44..76938cf13 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import edtf, { Date as EdtfDate, Interval as EdtfInterval } from 'edtf'; import { format, @@ -8,6 +8,7 @@ import { isValid, parse, } from 'date-fns'; +import { TimezoneService } from '@shared/services/timezone-service/timezone.service'; export enum DateQualifier { Approximate = 'approximate', @@ -70,6 +71,7 @@ export interface TimeModel { seconds?: string; format: TimeFormat; timezoneOffset?: string; + timezoneId?: string; } export interface DateTimeModel { @@ -85,6 +87,8 @@ export interface DateTimeModel { providedIn: 'root', }) export class EdtfService { + private readonly timezoneService = inject(TimezoneService); + toDateTimeModel(edtfString: string): DateTimeModel | null { try { if (!edtfString) { @@ -271,7 +275,7 @@ export class EdtfService { // Strip any time/timezone the library may append (e.g. T00:00:00.000Z) let result = edtfObject.toEDTF().replace(/T.*$/, ''); - const timeStr = hasTime ? this.buildTimeString(time) : ''; + const timeStr = hasTime ? this.buildTimeString(date, time) : ''; if (timeStr) { result = `${result}${timeStr}`; @@ -410,7 +414,7 @@ export class EdtfService { return parts.join('-'); } - private buildTimeString(time: TimeModel): string { + private buildTimeString(date: DateModel, time: TimeModel): string { if (!time?.hours) return ''; const converted = this.parseTimeAs24Hour(time); @@ -418,18 +422,113 @@ export class EdtfService { throw new Error('Invalid time'); } - const timezoneOffset = time.timezoneOffset ?? this.localTimezoneOffset(); + // A chosen timezone wins, because its offset depends on the date being + // edited; an unusable one falls back to whatever the string was parsed + // with. With neither, the time is written unmarked rather than stamped + // with the offset of whoever happens to be editing — clearing the + // timezone has to actually clear it, and the reader's own zone was never + // a fact about the item. + const timezoneOffset = + this.getTimezoneOffset(date, time, time.timezoneId) ?? + time.timezoneOffset ?? + ''; const pad = (n: number): string => String(n).padStart(2, '0'); 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}`; + /** + * An EDTF string only carries an offset, and an offset cannot be turned back + * into a place, so the identifier the item stores is stamped onto the parsed + * model here. An item holding a zone but no date still yields a model, so + * the pickers can show that zone instead of silently replacing it. + */ + withTimezone( + dateTimeModel: DateTimeModel | null, + timezone: unknown, + ): DateTimeModel | null { + const timezoneId = + this.timezoneService.resolveTimezoneId(timezone) ?? + this.inferTimezoneIdFromOffset(dateTimeModel) ?? + undefined; + + if (!dateTimeModel) { + return timezoneId + ? { + date: { year: '', month: '', day: '' }, + time: { ...DEFAULT_TIME, timezoneId }, + } + : null; + } + + return { + ...dateTimeModel, + time: { ...dateTimeModel.time, timezoneId }, + ...(dateTimeModel.endTime + ? { endTime: { ...dateTimeModel.endTime, timezoneId } } + : {}), + }; + } + + /** + * An item written before timezones were stored carries no place, only the + * offset its EDTF string was stamped with. An offset belongs to many places + * at once, so the zone recovered from it is a guess rather than a fact — but + * it reads the same offset the date already has, and it becomes the item's + * stored zone the next time the date is saved. + */ + private inferTimezoneIdFromOffset( + dateTimeModel: DateTimeModel | null, + ): string | null { + const timezoneOffset = + dateTimeModel?.time?.timezoneOffset ?? + dateTimeModel?.endTime?.timezoneOffset; + return this.timezoneService.getFirstTimezoneIdForOffset(timezoneOffset); + } + + /** + * A timezone only means something next to a time, since a bare date carries + * no offset. Returns the identifier worth storing on the item, or null when + * neither side of the model holds a time that can be read. + */ + getPersistableTimezoneId(dateTimeModel: DateTimeModel): string | null { + if (!dateTimeModel) { + return null; + } + const hasReadableTime = + this.isTimeReadable(dateTimeModel.time) || + this.isTimeReadable(dateTimeModel.endTime); + return hasReadableTime ? (dateTimeModel.time?.timezoneId ?? null) : null; + } + + private isTimeReadable(time?: TimeModel): boolean { + return !!time?.hours && this.parseTimeAs24Hour(time) !== null; + } + + /** + * The '+/-HH:MM' a zone was on at the given wall-clock reading, or null when + * the zone or the date is incomplete. A timezone alone is not enough: the + * same zone sits on different offsets across daylight saving and history. + */ + getTimezoneOffset( + date: DateModel, + time: TimeModel, + timezoneId: string | undefined, + ): string | null { + if (!timezoneId || !date?.year || !date?.month || !date?.day) { + return null; + } + const time24Hour = this.parseTimeAs24Hour(time); + if (!time24Hour) { + return null; + } + return this.timezoneService.getOffsetForWallClock(timezoneId, { + year: parseInt(date.year, 10), + month: parseInt(date.month, 10), + day: parseInt(date.day, 10), + hour: time24Hour.hour, + minute: time24Hour.minute, + second: time24Hour.second, + }); } private extDateToDateTimeModel( @@ -494,6 +593,9 @@ export class EdtfService { }; } + // Only an explicit '+HH:MM' or '-HH:MM' is read as an offset. 'Z' is UTC but + // names no place, and an unmarked time names neither, so both leave the + // offset absent and nothing is inferred from them. private extractRawTime(edtfString: string): { hours: number; minutes: number; @@ -585,47 +687,6 @@ export class EdtfService { return model; } - buildReferenceDate(date: DateModel, time: TimeModel): Date { - const year = parseInt(date?.year ?? '', 10); - if (Number.isNaN(year)) return new Date(); - const month = date.month ? parseInt(date.month, 10) - 1 : 0; - const day = date.day ? parseInt(date.day, 10) : 1; - const time24 = time?.hours ? this.parseTimeAs24Hour(time) : null; - return new Date( - year, - month, - day, - time24?.hour ?? 0, - time24?.minute ?? 0, - time24?.second ?? 0, - ); - } - - browserTimezoneAbbreviation(date: DateModel, time: TimeModel): string { - if (!time?.hours) return ''; - try { - const referenceDate = this.buildReferenceDate(date, time); - const parts = new Intl.DateTimeFormat('en-US', { - timeZoneName: 'short', - }).formatToParts(referenceDate); - const timezoneName = - parts.find((part) => part.type === 'timeZoneName')?.value ?? ''; - return this.normalizeTimezoneOffsetDisplay(timezoneName); - } catch { - return ''; - } - } - - // Intl 'short' renders offset-only zones as e.g. GMT+3 or GMT+5:30; - // rewrite the offset part to the canonical +/-HH:MM form (GMT+03:00). - private normalizeTimezoneOffsetDisplay(timezoneName: string): string { - const offsetMatch = /([+-])(\d{1,2})(?::(\d{2}))?/.exec(timezoneName); - if (!offsetMatch) return timezoneName; - const [rawOffset, sign, offsetHours, offsetMinutes] = offsetMatch; - const normalizedOffset = `${sign}${offsetHours.padStart(2, '0')}:${offsetMinutes ?? '00'}`; - return timezoneName.replace(rawOffset, normalizedOffset); - } - parseTimeAs24Hour( time: TimeModel, ): { hour: number; minute: number; second: number } | null { 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}`; + } +}