Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/gb3 1361 time service (dayjs.utils) #4

Merged
merged 13 commits into from
Oct 2, 2024
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ match. This is because there are times when you _might_ want to deviate from the
> 9. [Application Initialization based on share link](#application-initialization-based-on-share-link)
> 10. [Adding new NPM packages](#adding-new-npm-packages)
> 11. [Feature flags](#feature-flags)
> 12. [Handling of date objects](#handling-of-date-objects)

### The `ActiveMapItem` class

Expand Down Expand Up @@ -600,6 +601,12 @@ Feature flags can be used to toggle features throughout the application. They wo
- The `FeatureFlagsService` and its `getFeatureFlag` method is used to access the feature flags.
- For convenience, the `FeatureFlagDirective` can be used to toggle elements based on a feature flag.

### Handling of date objects

Currently, we are using [dayjs](https://day.js.org/) to handle date objects. In order to have a high degree of abstraction and to be able to easily replace the library (i.e. using native Javascript features like `Intl`),
all date handlings are done via the `TimeService` interface, which is implemented as e.g. the `DayjsService`. Currently, the actual implementation is injected via the `timeServiceFactory`; and as a convenience, this is also
added in `test.ts` so it does not have to be provided for each and every test.

## Git conventions

### Branching strategy
Expand Down
4 changes: 4 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {EsriMapLoaderService} from './map/services/esri-services/esri-map-loader
import {MapLoaderService} from './map/interfaces/map-loader.service';
import {DevModeBannerComponent} from './shared/components/dev-mode-banner/dev-mode-banner.component';
import {SkipLinkComponent} from './shared/components/skip-link/skip-link.component';
import {TimeService} from './shared/interfaces/time-service.interface';
import {timeServiceFactory} from './shared/factories/time-service.factory';

// necessary for the locale 'de-CH' to work
// see https://stackoverflow.com/questions/46419026/missing-locale-data-for-the-locale-xxx-with-angular
Expand All @@ -41,6 +43,7 @@ export const MAP_SERVICE = new InjectionToken<MapService>('MapService');
export const MAP_LOADER_SERVICE = new InjectionToken<MapLoaderService>('MapLoaderService');
export const NEWS_SERVICE = new InjectionToken<NewsService>('NewsService');
export const GRAV_CMS_SERVICE = new InjectionToken<GravCmsService>('GravCmsService');
export const TIME_SERVICE = new InjectionToken<TimeService>('TimeService');

@NgModule({
declarations: [AppComponent],
Expand All @@ -62,6 +65,7 @@ export const GRAV_CMS_SERVICE = new InjectionToken<GravCmsService>('GravCmsServi
{provide: ErrorHandler, deps: [Router, ErrorHandlerService, EmbeddedErrorHandlerService], useFactory: errorHandlerServiceFactory},
{provide: MAP_SERVICE, useClass: EsriMapService},
{provide: MAP_LOADER_SERVICE, useClass: EsriMapLoaderService},
{provide: TIME_SERVICE, useFactory: timeServiceFactory},
{provide: NEWS_SERVICE, deps: [KTZHNewsService, KTZHNewsServiceMock, ConfigService], useFactory: newsFactory},
{provide: GRAV_CMS_SERVICE, deps: [GravCmsService, GravCmsServiceMock, ConfigService], useFactory: gravCmsFactory},
{provide: LOCALE_ID, useValue: 'de-CH'},
Expand Down
60 changes: 26 additions & 34 deletions src/app/map/components/time-slider/time-slider.component.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges} from '@angular/core';
import {Component, EventEmitter, Inject, Input, OnChanges, OnInit, Output, SimpleChanges} from '@angular/core';
import {TimeExtent} from '../../interfaces/time-extent.interface';
import {TimeSliderConfiguration, TimeSliderLayerSource} from '../../../shared/interfaces/topic.interface';
import dayjs, {ManipulateType} from 'dayjs';
import {TimeSliderService} from '../../services/time-slider.service';
import {TimeExtentUtils} from '../../../shared/utils/time-extent.utils';
import duration from 'dayjs/plugin/duration';
import {MatDatepicker} from '@angular/material/datepicker';

dayjs.extend(duration);
import {TIME_SERVICE} from '../../../app.module';
import {TimeService} from '../../../shared/interfaces/time-service.interface';
import {DateUnit} from '../../../shared/types/date-unit.type';

// There is an array (`allowedDatePickerManipulationUnits`) and a new union type (`DatePickerManipulationUnits`) for two reasons:
// To be able to extract a union type subset of `ManipulateType` AND to have an array used to check if a given value is in said union type.
// => more infos: https://stackoverflow.com/questions/50085494/how-to-check-if-a-given-value-is-in-a-union-type-array
const allowedDatePickerManipulationUnits = ['years', 'months', 'days'] as const; // TS3.4 syntax
type DatePickerManipulationUnits = Extract<ManipulateType, (typeof allowedDatePickerManipulationUnits)[number]>;
type DatePickerManipulationUnits = Extract<DateUnit, (typeof allowedDatePickerManipulationUnits)[number]>;
type DatePickerStartView = 'month' | 'year' | 'multi-year';

@Component({
Expand Down Expand Up @@ -47,7 +45,10 @@ export class TimeSliderComponent implements OnInit, OnChanges {
public datePickerStartView: DatePickerStartView = 'month';
private datePickerUnit: DatePickerManipulationUnits = 'days';

constructor(private readonly timeSliderService: TimeSliderService) {}
constructor(
private readonly timeSliderService: TimeSliderService,
@Inject(TIME_SERVICE) private readonly timeService: TimeService,
) {}

public ngOnInit() {
this.availableDates = this.timeSliderService.createStops(this.timeSliderConfiguration);
Expand All @@ -57,7 +58,7 @@ export class TimeSliderComponent implements OnInit, OnChanges {
this.timeExtentDisplay = {start: this.initialTimeExtent.start, end: this.initialTimeExtent.end};
this.firstSliderPosition = this.findPositionOfDate(this.timeExtent.start) ?? 0;
this.secondSliderPosition = this.timeSliderConfiguration.range ? undefined : this.findPositionOfDate(this.timeExtent.end);
this.hasSimpleCurrentValue = this.isRangeExactlyOneOfSingleTimeUnit(this.timeSliderConfiguration.range);
this.hasSimpleCurrentValue = this.isStringSingleTimeUnitRange(this.timeSliderConfiguration.range);

// date picker
this.hasDatePicker = this.isRangeContinuousWithinAllowedTimeUnits(this.timeSliderConfiguration);
Expand Down Expand Up @@ -101,19 +102,19 @@ export class TimeSliderComponent implements OnInit, OnChanges {

// correct the thumb that was modified with the calculated time extent if necessary (e.g. enforcing a minimal range)
const hasStartTimeBeenCorrected =
TimeExtentUtils.calculateDifferenceBetweenDates(newValidatedTimeExtent.start, newTimeExtent.start) > 0;
this.timeService.calculateDifferenceBetweenDates(newValidatedTimeExtent.start, newTimeExtent.start) > 0;
if (hasStartTimeBeenCorrected) {
this.firstSliderPosition = this.findPositionOfDate(newValidatedTimeExtent.start) ?? 0;
}
const hasEndTimeBeenCorrected = TimeExtentUtils.calculateDifferenceBetweenDates(newValidatedTimeExtent.end, newTimeExtent.end) > 0;
const hasEndTimeBeenCorrected = this.timeService.calculateDifferenceBetweenDates(newValidatedTimeExtent.end, newTimeExtent.end) > 0;
if (!this.timeSliderConfiguration.range && hasEndTimeBeenCorrected) {
this.secondSliderPosition = this.findPositionOfDate(newValidatedTimeExtent.end) ?? 0;
}

// overwrite the current time extent and trigger the corresponding event if the new validated time extent is different from the previous one
if (
TimeExtentUtils.calculateDifferenceBetweenDates(this.timeExtent.start, newValidatedTimeExtent.start) > 0 ||
TimeExtentUtils.calculateDifferenceBetweenDates(this.timeExtent.end, newValidatedTimeExtent.end) > 0
this.timeService.calculateDifferenceBetweenDates(this.timeExtent.start, newValidatedTimeExtent.start) > 0 ||
this.timeService.calculateDifferenceBetweenDates(this.timeExtent.end, newValidatedTimeExtent.end) > 0
) {
this.timeExtent = newValidatedTimeExtent;
this.changeTimeExtentEvent.emit(this.timeExtent);
Expand Down Expand Up @@ -145,8 +146,12 @@ export class TimeSliderComponent implements OnInit, OnChanges {
if (eventDate === null) {
return;
}

// format the given event date to the configured time format and back to ensure that it is a valid date within the current available dates
const date = dayjs(dayjs(eventDate).format(this.timeSliderConfiguration.dateFormat), this.timeSliderConfiguration.dateFormat).toDate();
const date = this.timeService.createDateFromString(
this.timeService.getDateAsFormattedString(eventDate, this.timeSliderConfiguration.dateFormat),
this.timeSliderConfiguration.dateFormat,
);
const position = this.findPositionOfDate(date);
if (position !== undefined) {
if (changedMinimumDate) {
Expand All @@ -158,23 +163,8 @@ export class TimeSliderComponent implements OnInit, OnChanges {
}
}

/**
* Returns `true` if the given range is defined and is exactly one of a single time unit (year, month, ...).
* If the optional parameter `allowedTimeUnits` is given then only the units in there are allowed; all other return `false` as well.
* @param range The range (in ISO-8601 time span format) to be evaluated
*
* @example
* `P1Y1M` is a duration of one year AND one month which is more than one time unit; therefore is the result `false`
* `P2Y` is a duration of two years which is more than one of a single time unit; therefore is the result `false`
* `P1D` is a duration of one day which is exactly one of a single time unit; therefore the result is `true`
*/
private isRangeExactlyOneOfSingleTimeUnit(range: string | null | undefined): boolean {
if (range) {
const rangeDuration = dayjs.duration(range);
const unit = TimeExtentUtils.extractUniqueUnitFromDuration(rangeDuration);
return unit !== undefined && TimeExtentUtils.getDurationAsNumber(rangeDuration, unit) === 1;
}
return false;
private isStringSingleTimeUnitRange(range: string | null | undefined): boolean {
return range ? this.timeService.isStringSingleTimeUnitRange(range) : false;
}

/**
Expand All @@ -201,23 +191,25 @@ export class TimeSliderComponent implements OnInit, OnChanges {
}

private extractUniqueDatePickerUnitFromDateFormat(dateFormat: string): DatePickerManipulationUnits | undefined {
const unit = TimeExtentUtils.extractUniqueUnitFromDateFormat(dateFormat);
const unit = this.timeSliderService.extractUniqueUnitFromDateFormat(dateFormat);
if (unit !== undefined && allowedDatePickerManipulationUnits.some((allowedUnit) => allowedUnit === unit)) {
return unit as DatePickerManipulationUnits;
}
return undefined;
}

private isLayerSourceContinuous(layerSource: TimeSliderLayerSource, unit: DatePickerManipulationUnits): boolean {
const dateAsAscendingSortedNumbers = layerSource.layers.map((layer) => dayjs(layer.date, unit).get(unit)).sort((a, b) => a - b);
const dateAsAscendingSortedNumbers = layerSource.layers
.map((layer) => this.timeService.createPartialFromString(layer.date, unit))
.sort((a, b) => a - b);
// all date numbers must be part of a continuous and strictly monotonously rising series each with exactly
// one step between them: `date[0] = x` => `date[n] = x + n`
return !dateAsAscendingSortedNumbers.some((dateAsNumber, index) => dateAsNumber !== dateAsAscendingSortedNumbers[0] + index);
}

private findPositionOfDate(date: Date): number | undefined {
const index = this.availableDates.findIndex(
(availableDate) => TimeExtentUtils.calculateDifferenceBetweenDates(availableDate, date) === 0,
(availableDate) => this.timeService.calculateDifferenceBetweenDates(availableDate, date) === 0,
);
return index === -1 ? undefined : index;
}
Expand Down
16 changes: 0 additions & 16 deletions src/app/map/interfaces/time-slider.service.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/app/map/models/implementations/gb2-wms.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {FilterConfiguration, Map, MapLayer, SearchConfiguration, TimeSliderConfiguration} from '../../../shared/interfaces/topic.interface';
import {TimeExtent} from '../../interfaces/time-extent.interface';
import {TimeExtentUtils} from '../../../shared/utils/time-extent.utils';
import {AbstractActiveMapItemSettings, ActiveMapItem} from '../active-map-item.model';
import {AddToMapVisitor} from '../../interfaces/add-to-map.visitor';

Expand All @@ -23,7 +22,7 @@ export class Gb2WmsSettings extends AbstractActiveMapItemSettings {
this.layers = layer ? [layer] : map.layers;
this.timeSliderConfiguration = map.timeSliderConfiguration;
if (map.timeSliderConfiguration) {
this.timeSliderExtent = timeExtent ?? TimeExtentUtils.createInitialTimeSliderExtent(map.timeSliderConfiguration);
this.timeSliderExtent = timeExtent ?? map.initialTimeSliderExtent;
}
this.filterConfigurations = filterConfigurations ?? map.filterConfigurations;
this.searchConfigurations = map.searchConfigurations;
Expand Down
19 changes: 13 additions & 6 deletions src/app/map/pipes/date-to-string.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import {DateToStringPipe} from './date-to-string.pipe';
import {TestBed} from '@angular/core/testing';
import {TIME_SERVICE} from '../../app.module';
import {TimeService} from '../../shared/interfaces/time-service.interface';

describe('DateToStringPipe', () => {
it('create an instance', () => {
const pipe = new DateToStringPipe();
let pipe: DateToStringPipe;
let timeService: TimeService;

beforeEach(() => {
TestBed.configureTestingModule({});
timeService = TestBed.inject(TIME_SERVICE);
pipe = new DateToStringPipe(timeService);
});

it('creates an instance', () => {
expect(pipe).toBeTruthy();
});

it('formats a defined date', () => {
const pipe = new DateToStringPipe();

const date = new Date(2023, 1, 3); // monthIndex + 1 === month
const dateFormat = 'YYYY-MM-DD';

Expand All @@ -19,8 +28,6 @@ describe('DateToStringPipe', () => {
});

it('formats an undefined date', () => {
const pipe = new DateToStringPipe();

const date = undefined;
const dateFormat = 'YYYY-MM-DD';

Expand Down
9 changes: 6 additions & 3 deletions src/app/map/pipes/date-to-string.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {Pipe, PipeTransform} from '@angular/core';
import dayjs from 'dayjs';
import {Inject, Pipe, PipeTransform} from '@angular/core';
import {TIME_SERVICE} from '../../app.module';
import {TimeService} from '../../shared/interfaces/time-service.interface';

@Pipe({
name: 'dateToString',
standalone: true,
})
export class DateToStringPipe implements PipeTransform {
constructor(@Inject(TIME_SERVICE) private readonly timeService: TimeService) {}

public transform(value: Date | undefined, dateFormat: string): string {
return value ? dayjs(value).format(dateFormat) : '';
return value ? this.timeService.getDateAsFormattedString(value, dateFormat) : '';
}
}
20 changes: 12 additions & 8 deletions src/app/map/pipes/time-extent-to-string.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import {TimeExtentToStringPipe} from './time-extent-to-string.pipe';
import {TimeExtent} from '../interfaces/time-extent.interface';
import {TestBed} from '@angular/core/testing';
import {TIME_SERVICE} from '../../app.module';
import {TimeService} from '../../shared/interfaces/time-service.interface';

describe('TimeExtentToStringPipe', () => {
it('create an instance', () => {
const pipe = new TimeExtentToStringPipe();
let pipe: TimeExtentToStringPipe;
let timeService: TimeService;

beforeEach(() => {
TestBed.configureTestingModule({});
timeService = TestBed.inject(TIME_SERVICE);
pipe = new TimeExtentToStringPipe(timeService);
});
it('creates an instance', () => {
expect(pipe).toBeTruthy();
});

it('formats a defined time extent', () => {
const pipe = new TimeExtentToStringPipe();

const timeExtent: TimeExtent = {
start: new Date(2023, 1, 3),
end: new Date(2024, 2, 4),
Expand All @@ -24,8 +32,6 @@ describe('TimeExtentToStringPipe', () => {
});

it('formats a defined time extent using simple values', () => {
const pipe = new TimeExtentToStringPipe();

const timeExtent: TimeExtent = {
start: new Date(2023, 1, 3),
end: new Date(2024, 2, 4),
Expand All @@ -40,8 +46,6 @@ describe('TimeExtentToStringPipe', () => {
});

it('formats an undefined time extent', () => {
const pipe = new TimeExtentToStringPipe();

const timeExtent = undefined;
const dateFormat = 'YYYY-MM-DD';
const hasSimpleCurrentValue = true;
Expand Down
9 changes: 6 additions & 3 deletions src/app/map/pipes/time-extent-to-string.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {Pipe, PipeTransform} from '@angular/core';
import {Inject, Pipe, PipeTransform} from '@angular/core';
import {TimeExtent} from '../interfaces/time-extent.interface';
import dayjs from 'dayjs';
import {TIME_SERVICE} from '../../app.module';
import {TimeService} from '../../shared/interfaces/time-service.interface';

@Pipe({
name: 'timeExtentToString',
standalone: true,
})
export class TimeExtentToStringPipe implements PipeTransform {
constructor(@Inject(TIME_SERVICE) private readonly timeService: TimeService) {}

public transform(timeExtent: TimeExtent | undefined, dateFormat: string, hasSimpleCurrentValue: boolean): string {
if (!timeExtent) {
return '';
Expand All @@ -17,6 +20,6 @@ export class TimeExtentToStringPipe implements PipeTransform {
}

private convertDateToString(value: Date, dateFormat: string): string {
return value ? dayjs(value).format(dateFormat) : '';
return value ? this.timeService.getDateAsFormattedString(value, dateFormat) : '';
}
}
Loading
Loading