Skip to content

Multi-timezone support in Angular #22619

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

Open
wants to merge 14 commits into
base: rel-9.2
Choose a base branch
from
1 change: 1 addition & 0 deletions npm/ng-packs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"just-compare": "^2.0.0",
"lerna": "^4.0.0",
"lint-staged": "^13.0.0",
"luxon": "^3.6.1",
"ng-packagr": "~19.1.0",
"ng-zorro-antd": "~19.0.0",
"nx": "~20.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,33 @@
<ng-container *abpPermission="prop.permission; runChangeDetection: false">
<ng-container *abpVisible="row['_' + prop.name]?.visible">
@if (!row['_' + prop.name].component) {
<div
[innerHTML]="
@if (prop.type === 'datetime' || prop.type === 'date' || prop.type === 'time') {
<div
[innerHTML]="
!prop.isExtra
? (row['_' + prop.name]?.value | async | abpUtcToLocal:prop.type)
: ('::' + (row['_' + prop.name]?.value | async | abpUtcToLocal:prop.type) | abpLocalization)
"
(click)="
prop.action && prop.action({ getInjected: getInjected, record: row, index: i })
"
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
} @else {
<div
[innerHTML]="
!prop.isExtra
? (row['_' + prop.name]?.value | async)
: ('::' + (row['_' + prop.name]?.value | async) | abpLocalization)
"
(click)="
(click)="
prop.action && prop.action({ getInjected: getInjected, record: row, index: i })
"
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
[ngClass]="entityPropTypeClasses[prop.type]"
[class.pointer]="prop.action"
></div>
}
} @else {
<ng-container
*ngComponentOutlet="
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
TemplateRef,
TrackByFunction,
} from '@angular/core';
import { AsyncPipe, formatDate, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
import { AsyncPipe, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';

import { Observable, filter, map } from 'rxjs';

Expand All @@ -24,13 +24,12 @@ import { NgxDatatableModule } from '@swimlane/ngx-datatable';
import {
ABP,
ConfigStateService,
getShortDateFormat,
getShortDateShortTimeFormat,
getShortTimeFormat,
ListService,
LocalizationModule,
PermissionDirective,
PermissionService,
TimezoneService,
UtcToLocalPipe,
} from '@abp/ng.core';
import {
AbpVisibleDirective,
Expand Down Expand Up @@ -64,6 +63,7 @@ const DEFAULT_ACTIONS_COLUMN_WIDTH = 150;
NgxDatatableListDirective,
PermissionDirective,
LocalizationModule,
UtcToLocalPipe,
AsyncPipe,
NgTemplateOutlet,
NgComponentOutlet,
Expand All @@ -77,6 +77,7 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
protected readonly cdr = inject(ChangeDetectorRef);
protected readonly locale = inject(LOCALE_ID);
protected readonly config = inject(ConfigStateService);
protected readonly timeZoneService = inject(TimezoneService);
protected readonly entityPropTypeClasses = inject(ENTITY_PROP_TYPE_CLASSES);
protected readonly permissionService = inject(PermissionService);

Expand Down Expand Up @@ -134,10 +135,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
(this.columnWidths as any) = widths;
}

private getDate(value: Date | undefined, format: string | undefined) {
return value && format ? formatDate(value, format, this.locale) : '';
}

private getIcon(value: boolean) {
return value
? '<div class="text-success"><i class="fa fa-check" aria-hidden="true"></i></div>'
Expand All @@ -156,12 +153,6 @@ export class ExtensibleTableComponent<R = any> implements OnChanges, AfterViewIn
switch (prop.type) {
case ePropType.Boolean:
return this.getIcon(value);
case ePropType.Date:
return this.getDate(value, getShortDateFormat(this.config));
case ePropType.Time:
return this.getDate(value, getShortTimeFormat(this.config));
case ePropType.DateTime:
return this.getDate(value, getShortDateShortTimeFormat(this.config));
case ePropType.Enum:
return this.getEnum(value, prop.enumList || []);
default:
Expand Down
14 changes: 13 additions & 1 deletion npm/ng-packs/packages/core/src/lib/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
provideHttpClient,
withInterceptorsFromDi,
withXsrfConfiguration,
HTTP_INTERCEPTORS,
} from '@angular/common/http';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
Expand Down Expand Up @@ -30,6 +31,8 @@ import { ShortTimePipe } from './pipes/short-time.pipe';
import { ShortDatePipe } from './pipes/short-date.pipe';
import { SafeHtmlPipe } from './pipes/safe-html.pipe';
import { provideAbpCoreChild, provideAbpCore, withOptions } from './providers';
import { UtcToLocalPipe } from './pipes';
import { TimezoneInterceptor } from './interceptors';

const standaloneDirectives = [
AutofocusDirective,
Expand Down Expand Up @@ -72,6 +75,7 @@ const standaloneDirectives = [
ReactiveFormsModule,
RouterModule,
LocalizationModule,
UtcToLocalPipe,
...standaloneDirectives,
],
declarations: [
Expand All @@ -86,7 +90,15 @@ const standaloneDirectives = [
ShortTimePipe,
ShortDatePipe,
],
providers: [LocalizationPipe, provideHttpClient(withInterceptorsFromDi())],
providers: [
LocalizationPipe,
provideHttpClient(withInterceptorsFromDi()),
{
provide: HTTP_INTERCEPTORS,
useClass: TimezoneInterceptor,
multi: true,
},
],
})
export class BaseCoreModule {}

Expand Down
1 change: 1 addition & 0 deletions npm/ng-packs/packages/core/src/lib/interceptors/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './api.interceptor';
export * from './timezone.interceptor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { inject, Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { TimezoneService } from '../services';
import { Observable } from 'rxjs';

@Injectable({
providedIn: 'root',
})
export class TimezoneInterceptor implements HttpInterceptor {
protected readonly timezoneService = inject(TimezoneService);

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.timezoneService.isUtcClockEnabled) {
return next.handle(req);
}
const timezone = this.timezoneService.timezone;
if (timezone) {
req = req.clone({
setHeaders: {
__timezone: timezone,
},
});
}
return next.handle(req);
}
}
1 change: 1 addition & 0 deletions npm/ng-packs/packages/core/src/lib/pipes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './to-injector.pipe';
export * from './short-date.pipe';
export * from './short-time.pipe';
export * from './short-date-time.pipe';
export * from './utc-to-local.pipe';
50 changes: 50 additions & 0 deletions npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Pipe, PipeTransform, Injectable, inject, LOCALE_ID } from '@angular/core';
import { ConfigStateService, LocalizationService, TimeService, TimezoneService } from '../services';
import { getShortDateFormat, getShortDateShortTimeFormat, getShortTimeFormat } from '../utils';

@Injectable()
@Pipe({
name: 'abpUtcToLocal',
})
export class UtcToLocalPipe implements PipeTransform {
protected readonly timezoneService = inject(TimezoneService);
protected readonly timeService = inject(TimeService);
protected readonly configState = inject(ConfigStateService);
protected readonly localizationService = inject(LocalizationService);
protected readonly locale = inject(LOCALE_ID);

transform(
value: string | Date | null | undefined,
type: 'date' | 'datetime' | 'time',
): string | Date {
if (!value) return '';

const date = new Date(value);
if (isNaN(date.getTime())) return '';

const format = this.getFormat(type);

try {
if (this.timezoneService.isUtcClockEnabled) {
const timeZone = this.timezoneService.timezone;
return this.timeService.formatDateWithStandardOffset(date, format, timeZone);
} else {
return this.timeService.formatWithoutTimeZone(date, format);
}
} catch (err) {
return value;
}
}

private getFormat(propType: 'date' | 'datetime' | 'time'): string {
switch (propType) {
case 'date':
return getShortDateFormat(this.configState);
case 'time':
return getShortTimeFormat(this.configState);
case 'datetime':
default:
return getShortDateShortTimeFormat(this.configState);
}
}
}
2 changes: 2 additions & 0 deletions npm/ng-packs/packages/core/src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export * from './window.service';
export * from './internet-connection-service';
export * from './local-storage-listener.service';
export * from './title-strategy.service';
export * from './timezone.service';
export * from './time.service';
107 changes: 107 additions & 0 deletions npm/ng-packs/packages/core/src/lib/services/time.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { inject, Injectable, LOCALE_ID } from '@angular/core';
import { DateTime } from 'luxon';

@Injectable({
providedIn: 'root',
})
export class TimeService {
private locale = inject(LOCALE_ID);

/**
* Returns the current date and time in the specified timezone.
*
* @param zone - An IANA timezone name (e.g., 'Europe/Istanbul', 'UTC'); defaults to the system's local timezone.
* @returns A Luxon DateTime instance representing the current time in the given timezone.
*/
now(zone = 'local'): DateTime {
return DateTime.now().setZone(zone);
}

/**
* Converts the input date to the specified timezone, applying any timezone and daylight saving time (DST) adjustments.
*
* This method:
* 1. Parses the input value into a Luxon DateTime object.
* 2. Applies the specified IANA timezone, including any DST shifts based on the given date.
*
* @param value - The ISO string or Date object to convert.
* @param zone - An IANA timezone name (e.g., 'America/New_York').
* @returns A Luxon DateTime instance adjusted to the specified timezone and DST rules.
*/
toZone(value: string | Date, zone: string): DateTime {
return DateTime.fromISO(value instanceof Date ? value.toISOString() : value, {
zone,
});
}

/**
* Formats the input date by applying timezone and daylight saving time (DST) adjustments.
*
* This method:
* 1. Converts the input date to the specified timezone.
* 2. Formats the result using the given format and locale, reflecting any timezone or DST shifts.
*
* @param value - The ISO string or Date object to format.
* @param format - The format string (default: 'ff').
* @param zone - Optional IANA timezone name (e.g., 'America/New_York'); defaults to the system's local timezone.
* @returns A formatted date string adjusted for the given timezone and DST rules.
*/
format(value: string | Date, format = 'ff', zone = 'local'): string {
return this.toZone(value, zone).setLocale(this.locale).toFormat(format);
}

/**
* Formats a date using the standard time offset (ignoring daylight saving time) for the specified timezone.
*
* This method:
* 1. Converts the input date to UTC.
* 2. Calculates the standard UTC offset for the given timezone (based on January 1st to avoid DST).
* 3. Applies the standard offset manually to the UTC time.
* 4. Formats the result using the specified format and locale, without applying additional timezone shifts.
*
* @param value - The ISO string or Date object to format.
* @param format - The Luxon format string (default: 'ff').
* @param zone - Optional IANA timezone name (e.g., 'America/New_York'); if omitted, system local timezone is used.
* @returns A formatted date string adjusted by standard time (non-DST).
*/
formatDateWithStandardOffset(value: string | Date, format = 'ff', zone?: string): string {
const utcDate =
typeof value === 'string'
? DateTime.fromISO(value, { zone: 'UTC' })
: DateTime.fromJSDate(value, { zone: 'UTC' });

if (!utcDate.isValid) return '';

const targetZone = zone ?? DateTime.local().zoneName;

const januaryDate = DateTime.fromObject(
{ year: utcDate.year, month: 1, day: 1 },
{ zone: targetZone },
);
const standardOffset = januaryDate.offset;
const dateWithStandardOffset = utcDate.plus({ minutes: standardOffset });

return dateWithStandardOffset.setZone('UTC').setLocale(this.locale).toFormat(format);
}

/**
* Formats the input date using its original clock time, without converting based on timezone or DST
*
* This method:
* 1. Converts the input date to ISO string.
* 2. Calculates the date time in UTC, keeping the local time.
* 3. Formats the result using the specified format and locale, without shifting timezones.
*
* @param value - The ISO string or Date object to format.
* @param format - The format string (default: 'ff').
* @returns A formatted date string without applying timezone.
*/
formatWithoutTimeZone(value: string | Date, format = 'ff'): string {
const isoString = value instanceof Date ? value.toISOString() : value;

const dateTime = DateTime.fromISO(isoString)
.setZone('utc', { keepLocalTime: true })
.setLocale(this.locale);
return dateTime.toFormat(format);
}
}
Loading
Loading