Skip to content

Commit

Permalink
Feature: Global timezone selector(UTC vs Local) (#1854)
Browse files Browse the repository at this point in the history
  • Loading branch information
timotheeguerin authored Jan 14, 2019
1 parent 5362dc7 commit c406564
Show file tree
Hide file tree
Showing 76 changed files with 821 additions and 281 deletions.
6 changes: 6 additions & 0 deletions definitions/luxon-ext.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@ declare module "luxon" {
interface DateTime {
toRelative(params?: { date?: DateTime }): string;
}

interface Zone {
readonly isValid: boolean;
readonly name: string;
readonly type: string;
}
}
1 change: 1 addition & 0 deletions src/@batch-flask/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export * from "./i18n";
export * from "./rxjs-operators";
export * from "./server-error";
export * from "./telemetry";
export * from "./timezone";
export * from "./user-configuration";
18 changes: 18 additions & 0 deletions src/@batch-flask/core/telemetry/telemetry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ export class TelemetryService {
this.track(pageView, TelemetryType.PageView);
}

/**
* Used to track if a setting is being used
*
* **ONLY** use for non personal data(No user path)
*
* @param name Name of the setting
* @param value Value used
*/
public trackSetting(name: string, value: string) {
this.trackEvent({
name: "Setting used",
properties: {
name,
value,
},
});
}

public track(telemetry: Telemetry, type: TelemetryType) {
if (!this._enable) { return; }
this._uploader.track(telemetry, type);
Expand Down
1 change: 1 addition & 0 deletions src/@batch-flask/core/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./i18n-testing.module";
export * from "./telemetry-testing.module";
export * from "./timezone-testing.module";
export * from "./mock-control-value-accessor";
50 changes: 50 additions & 0 deletions src/@batch-flask/core/testing/timezone-testing.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NgModule } from "@angular/core";
import { DEFAULT_TIMEZONE, TimeZoneService } from "@batch-flask/core/timezone";
import { DateTime } from "luxon";
import { BehaviorSubject } from "rxjs";

export class TestTimeZoneService {
public current = new BehaviorSubject({
name: "utc",
offsetNameShort: "UTC",
offsetNameLong: "UTC",
});
public setTimezone = jasmine.createSpy("setTimezone").and.callFake(this._setTimezone.bind(this));

private _setTimezone(name: string) {
name = name || "local";
const date = DateTime.local().setZone(name);
if (date.isValid) {
this.current.next({
name,
offsetNameShort: date.offsetNameShort,
offsetNameLong: date.offsetNameLong,
});
} else {
return this.current.next(DEFAULT_TIMEZONE);
}
}

}

@NgModule({
providers: [
{ provide: TimeZoneService, useClass: TestTimeZoneService },
],
})
export class TimeZoneTestingModule {
public static withZone() {
const service = new TestTimeZoneService();
service.current.next({
name: "local",
offsetNameShort: "local",
offsetNameLong: "local",
});
return {
ngModule: TimeZoneTestingModule,
providers: [
{ provide: TestTimeZoneService, useValue: service },
],
};
}
}
1 change: 1 addition & 0 deletions src/@batch-flask/core/timezone/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./timezone.service";
64 changes: 64 additions & 0 deletions src/@batch-flask/core/timezone/timezone.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { BehaviorSubject, Subscription } from "rxjs";
import { DEFAULT_TIMEZONE, TimeZone, TimeZoneService } from "./timezone.service";

describe("TimezoneService", () => {
let service: TimeZoneService;
let configSpy;
let telemetryServiceSpy;
let timezoneSetting: BehaviorSubject<string>;
let current: TimeZone;
let sub: Subscription;

beforeEach(() => {
timezoneSetting = new BehaviorSubject<string | null>(null);
configSpy = {
watch: jasmine.createSpy("watch").and.returnValue(timezoneSetting),
set: jasmine.createSpy("set"),
};
telemetryServiceSpy = {
trackSetting: jasmine.createSpy("trackSetting"),
};
service = new TimeZoneService(configSpy, telemetryServiceSpy);
sub = service.current.subscribe(x => current = x);
});

afterEach(() => {
if (sub) {
sub.unsubscribe();
}
});

it("should watch the settings", () => {
expect(configSpy.watch).toHaveBeenCalledOnce();
expect(configSpy.watch).toHaveBeenCalledWith("timezone");
});

it("default to local timezone when there is no settings", () => {
timezoneSetting.next(null);
expect(current).toEqual(DEFAULT_TIMEZONE);
});

it("default to local timezone when timezone setting is invalid", () => {
timezoneSetting.next("foobar");
expect(current).toEqual(DEFAULT_TIMEZONE);
timezoneSetting.next("America/Unkown_City");
expect(current).toEqual(DEFAULT_TIMEZONE);
});

it("picks UTC", () => {
timezoneSetting.next("utc");
expect(current).toEqual({
name: "utc",
offsetNameShort: "UTC",
offsetNameLong: "UTC",
});
});

it("updates the timezone", () => {
service.setTimezone("utc");
expect(configSpy.set).toHaveBeenCalledOnce();
expect(configSpy.set).toHaveBeenCalledWith("timezone", "utc");
expect(telemetryServiceSpy.trackSetting).toHaveBeenCalledOnce();
expect(telemetryServiceSpy.trackSetting).toHaveBeenCalledWith("timezone", "utc");
});
});
55 changes: 55 additions & 0 deletions src/@batch-flask/core/timezone/timezone.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable } from "@angular/core";
import { DateTime } from "luxon";
import { Observable } from "rxjs";
import { map, publishReplay, refCount } from "rxjs/operators";
import { TelemetryService } from "../telemetry";
import { BatchFlaskUserConfiguration, UserConfigurationService } from "../user-configuration";

const localDate = DateTime.local();
export const DEFAULT_TIMEZONE: TimeZone = {
name: "local",
offsetNameShort: localDate.offsetNameShort,
offsetNameLong: localDate.offsetNameLong,
};

export interface TimeZone {
name: string;
offsetNameShort: string;
offsetNameLong: string;
}

/**
* Service to handle timezone for dates in the app.
* By default use the local timezone
*/
@Injectable({ providedIn: "root" })
export class TimeZoneService {
public current: Observable<TimeZone>;

constructor(
private userConfiguration: UserConfigurationService<BatchFlaskUserConfiguration>,
private telemetryService: TelemetryService) {
this.current = this.userConfiguration.watch("timezone").pipe(
map((name): TimeZone => {
name = name || "local";
const date = DateTime.local().setZone(name);
if (date.isValid) {
return {
name,
offsetNameShort: date.offsetNameShort,
offsetNameLong: date.offsetNameLong,
};
} else {
return DEFAULT_TIMEZONE;
}
}),
publishReplay(1),
refCount(),
);
}

public async setTimezone(timezone: string) {
this.telemetryService.trackSetting("timezone", timezone);
return this.userConfiguration.set("timezone", timezone);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BehaviorSubject, Subscription } from "rxjs";
import { UserConfigurationService } from "./user-configuration.service";
import { BatchFlaskUserConfiguration, UserConfigurationService } from "./user-configuration.service";

interface Settings {
interface Settings extends BatchFlaskUserConfiguration {
foo: string;
bar: number;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { isNotNullOrUndefined } from "../rxjs-operators";

export const USER_CONFIGURATION_STORE = "USER_CONFIGURATION_STORE";

export interface BatchFlaskUserConfiguration {
timezone?: string;
}

export interface UserConfigurationStore<T> {
config: Observable<T>;

save(config: T): Promise<any>;
}

@Injectable({ providedIn: "root" })
export class UserConfigurationService<T extends {}> implements OnDestroy {
export class UserConfigurationService<T extends BatchFlaskUserConfiguration> implements OnDestroy {
public config: Observable<T>;

private _config = new BehaviorSubject<T | null>({} as any);
Expand Down
70 changes: 70 additions & 0 deletions src/@batch-flask/ui/date/date.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";

import { TimeZoneService } from "@batch-flask/core";
import { DateUtils } from "@batch-flask/utils";
import { DateTime } from "luxon";
import { BehaviorSubject } from "rxjs";
import { DateComponent } from "./date.component";

const date1 = new Date(Date.UTC(2017, 8, 3));
const date2 = new Date(Date.UTC(2015, 9, 4));

@Component({
template: `<bl-date [date]="date"></bl-date>`,
})
class TestComponent {
public date = date1;
}

describe("DateComponent", () => {
let fixture: ComponentFixture<TestComponent>;
let testComponent: TestComponent;
let de: DebugElement;

let timezoneServiceSpy;

beforeEach(() => {
timezoneServiceSpy = {
current: new BehaviorSubject({
name: "utc",
}),
};
TestBed.configureTestingModule({
imports: [],
declarations: [DateComponent, TestComponent],
providers: [
{ provide: TimeZoneService, useValue: timezoneServiceSpy },
],
});
fixture = TestBed.createComponent(TestComponent);
testComponent = fixture.componentInstance;
de = fixture.debugElement.query(By.css("bl-date"));
fixture.detectChanges();
});

it("shows the pretty date in the current timezone", () => {
const data = DateTime.fromJSDate(date1).setZone("utc");
expect(de.nativeElement.textContent).toContain(DateUtils.prettyDate(data));
});

it("updates the time in the given timezone", () => {
timezoneServiceSpy.current.next({
name: "America/Los_Angeles",
});
fixture.detectChanges();

const utcDate = DateTime.fromJSDate(date1).setZone("utc");
const data = DateTime.fromJSDate(date1).setZone("America/Los_Angeles");
expect(de.nativeElement.textContent).toContain(DateUtils.prettyDate(data));
expect(de.nativeElement.textContent).not.toContain(DateUtils.prettyDate(utcDate));
});

it("updates the date", () => {
testComponent.date = date2;
fixture.detectChanges();
const data = DateTime.fromJSDate(date2).setZone("utc");
expect(de.nativeElement.textContent).toContain(DateUtils.prettyDate(data));
});
});
38 changes: 35 additions & 3 deletions src/@batch-flask/ui/date/date.component.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,51 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges } from "@angular/core";
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, OnChanges, OnDestroy,
} from "@angular/core";
import { TimeZoneService } from "@batch-flask/core";
import { DateUtils } from "@batch-flask/utils";
import { DateTime } from "luxon";
import { BehaviorSubject, Subject, combineLatest } from "rxjs";
import { takeUntil } from "rxjs/operators";

@Component({
selector: "bl-date",
template: "{{formatedDate}}",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DateComponent implements OnChanges {
export class DateComponent implements OnChanges, OnDestroy {
@Input() public date: Date;

@HostBinding("attr.title") public title: string;

public formatedDate: string;

private _date = new BehaviorSubject(null);
private _destroy = new Subject();

constructor(private changeDetector: ChangeDetectorRef, private timezoneService: TimeZoneService) {
combineLatest(this.timezoneService.current, this._date).pipe(
takeUntil(this._destroy),
).subscribe(([zone, jsDate]) => {
if (jsDate) {
const date = DateTime.fromJSDate(jsDate).setZone(zone.name);
this.formatedDate = DateUtils.prettyDate(date);
this.title = date.toISO();
} else {
this.formatedDate = "-";
this.title = "-";
}
this.changeDetector.markForCheck();
});
}

public ngOnChanges(changes) {
if (changes.date) {
this.formatedDate = DateUtils.prettyDate(this.date);
this._date.next(this.date);
}
}

public ngOnDestroy() {
this._destroy.next();
this._destroy.complete();
}
}
Loading

0 comments on commit c406564

Please sign in to comment.