From 204c7360c3e345ad2084d827f6bf095fc3aeb27f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 5 Apr 2022 21:02:07 +0200 Subject: [PATCH] Feature/prepare for localized date format (#803) * Support localized date and number format * Update changelog --- CHANGELOG.md | 6 +++ .../interfaces/user-settings.interface.ts | 1 + .../src/app/user/update-user-setting.dto.ts | 6 ++- apps/api/src/app/user/user.controller.ts | 18 ++++---- apps/api/src/app/user/user.service.ts | 21 +++++---- apps/client/src/app/adapter/date-formats.ts | 12 +++-- .../admin-market-data-detail.component.ts | 12 +++-- .../admin-market-data.component.ts | 25 ++++++++--- .../admin-market-data/admin-market-data.html | 1 + .../admin-overview.component.ts | 2 - .../portfolio-performance.component.ts | 7 ++- .../pages/account/account-page.component.ts | 32 ++++++++++++- .../src/app/pages/account/account-page.html | 25 +++++++++++ .../app/pages/account/account-page.module.ts | 2 + apps/client/src/main.ts | 3 +- libs/common/src/lib/config.ts | 3 +- libs/common/src/lib/helper.ts | 45 ++++++++++++++++++- .../activities-table.component.ts | 6 ++- libs/ui/src/lib/value/value.component.ts | 17 ++++--- 19 files changed, 193 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e584bb0..1c918a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added support for localization (date and number format) in user settings + ## 1.131.1 - 04.04.2022 ### Fixed diff --git a/apps/api/src/app/user/interfaces/user-settings.interface.ts b/apps/api/src/app/user/interfaces/user-settings.interface.ts index fb4b2af3..ef3b03f1 100644 --- a/apps/api/src/app/user/interfaces/user-settings.interface.ts +++ b/apps/api/src/app/user/interfaces/user-settings.interface.ts @@ -1,5 +1,6 @@ export interface UserSettings { emergencyFund?: number; + locale?: string; isNewCalculationEngine?: boolean; isRestrictedView?: boolean; } diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 5af2f5f8..eaa41464 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsNumber, IsOptional } from 'class-validator'; +import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; export class UpdateUserSettingDto { @IsNumber() @@ -12,4 +12,8 @@ export class UpdateUserSettingDto { @IsBoolean() @IsOptional() isRestrictedView?: boolean; + + @IsString() + @IsOptional() + locale?: string; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 97cf25b6..5bd14cfa 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasRole, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, + Headers, HttpException, Inject, Param, @@ -63,8 +60,13 @@ export class UserController { @Get() @UseGuards(AuthGuard('jwt')) - public async getUser(@Param('id') id: string): Promise { - return this.userService.getUser(this.request.user); + public async getUser( + @Headers('accept-language') acceptLanguage: string + ): Promise { + return this.userService.getUser( + this.request.user, + acceptLanguage?.split(',')?.[0] + ); } @Post() @@ -118,7 +120,7 @@ export class UserController { }; for (const key in userSettings) { - if (userSettings[key] === false) { + if (userSettings[key] === false || userSettings[key] === null) { delete userSettings[key]; } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 43b3b52c..a4e9267e 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -33,14 +33,17 @@ export class UserService { private readonly subscriptionService: SubscriptionService ) {} - public async getUser({ - Account, - alias, - id, - permissions, - Settings, - subscription - }: UserWithSettings): Promise { + public async getUser( + { + Account, + alias, + id, + permissions, + Settings, + subscription + }: UserWithSettings, + aLocale = locale + ): Promise { const access = await this.prismaService.access.findMany({ include: { User: true @@ -63,8 +66,8 @@ export class UserService { accounts: Account, settings: { ...(Settings.settings), - locale, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, + locale: (Settings.settings).locale ?? aLocale, viewMode: Settings?.viewMode ?? ViewMode.DEFAULT } }; diff --git a/apps/client/src/app/adapter/date-formats.ts b/apps/client/src/app/adapter/date-formats.ts index 554f7c76..fdf32bef 100644 --- a/apps/client/src/app/adapter/date-formats.ts +++ b/apps/client/src/app/adapter/date-formats.ts @@ -1,16 +1,14 @@ -import { - DEFAULT_DATE_FORMAT, - DEFAULT_DATE_FORMAT_MONTH_YEAR -} from '@ghostfolio/common/config'; +import { DEFAULT_DATE_FORMAT_MONTH_YEAR } from '@ghostfolio/common/config'; +import { getDateFormatString } from '@ghostfolio/common/helper'; export const DateFormats = { display: { - dateInput: DEFAULT_DATE_FORMAT, + dateInput: getDateFormatString(), monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR, - dateA11yLabel: DEFAULT_DATE_FORMAT, + dateA11yLabel: getDateFormatString(), monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR }, parse: { - dateInput: DEFAULT_DATE_FORMAT + dateInput: getDateFormatString() } }; diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts index 9f935cb9..f28253b9 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts @@ -8,8 +8,11 @@ import { Output } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getDateFormatString, + getLocale +} from '@ghostfolio/common/helper'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { DataSource, MarketData } from '@prisma/client'; import { @@ -35,13 +38,14 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data- export class AdminMarketDataDetailComponent implements OnChanges, OnInit { @Input() dataSource: DataSource; @Input() dateOfFirstActivity: string; + @Input() locale = getLocale(); @Input() marketData: MarketData[]; @Input() symbol: string; @Output() marketDataChanged = new EventEmitter(); public days = Array(31); - public defaultDateFormat = DEFAULT_DATE_FORMAT; + public defaultDateFormat: string; public deviceType: string; public historicalDataItems: LineChartItem[]; public marketDataByMonth: { @@ -62,6 +66,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit { public ngOnInit() {} public ngOnChanges() { + this.defaultDateFormat = getDateFormatString(this.locale); + this.historicalDataItems = this.marketData.map((marketDataItem) => { return { date: format(marketDataItem.date, DATE_FORMAT), diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index a2900ae6..2229a360 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -7,8 +7,9 @@ import { } from '@angular/core'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; -import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { getDateFormatString } from '@ghostfolio/common/helper'; +import { UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { DataSource, MarketData } from '@prisma/client'; import { Subject } from 'rxjs'; @@ -23,9 +24,10 @@ import { takeUntil } from 'rxjs/operators'; export class AdminMarketDataComponent implements OnDestroy, OnInit { public currentDataSource: DataSource; public currentSymbol: string; - public defaultDateFormat = DEFAULT_DATE_FORMAT; + public defaultDateFormat: string; public marketData: AdminMarketDataItem[] = []; public marketDataDetails: MarketData[] = []; + public user: User; private unsubscribeSubject = new Subject(); @@ -35,8 +37,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService - ) {} + private dataService: DataService, + private userService: UserService + ) { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.defaultDateFormat = getDateFormatString( + this.user.settings.locale + ); + } + }); + } /** * Initializes the controller diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index 7638d611..725c75e2 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -65,6 +65,7 @@ = 100000 ? 0 : 2, duration: 1, - separator: `'` + separator: getNumberFormatGroup(this.locale) }).start(); } else if (this.performance?.currentValue === null) { this.unit = '%'; diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 8352dc35..072d9148 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -20,9 +20,11 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config'; +import { baseCurrency } from '@ghostfolio/common/config'; +import { getDateFormatString } from '@ghostfolio/common/helper'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { uniq } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; @@ -45,13 +47,14 @@ export class AccountPageComponent implements OnDestroy, OnInit { public coupon: number; public couponId: string; public currencies: string[] = []; - public defaultDateFormat = DEFAULT_DATE_FORMAT; + public defaultDateFormat: string; public deviceType: string; public hasPermissionForSubscription: boolean; public hasPermissionToCreateAccess: boolean; public hasPermissionToDeleteAccess: boolean; public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; + public locales = ['de', 'de-CH', 'en-GB', 'en-US']; public price: number; public priceId: string; public snackBarRef: MatSnackBarRef; @@ -101,6 +104,10 @@ export class AccountPageComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.defaultDateFormat = getDateFormatString( + this.user.settings.locale + ); + this.hasPermissionToCreateAccess = hasPermission( this.user.permissions, permissions.createAccess @@ -121,6 +128,9 @@ export class AccountPageComponent implements OnDestroy, OnInit { permissions.updateViewMode ); + this.locales.push(this.user.settings.locale); + this.locales = uniq(this.locales.sort()); + this.changeDetectorRef.markForCheck(); } }); @@ -143,6 +153,24 @@ export class AccountPageComponent implements OnDestroy, OnInit { this.update(); } + public onChangeUserSetting(aKey: string, aValue: string) { + this.dataService + .putUserSetting({ [aKey]: aValue }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onChangeUserSettings(aKey: string, aValue: string) { const settings = { ...this.user.settings, [aKey]: aValue }; diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 47f8a371..96cc04d1 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -111,6 +111,31 @@ +
+
+
Locale
+
+ Date and number format +
+
+
+ + + + {{ locale }} + + +
+
View Mode diff --git a/apps/client/src/app/pages/account/account-page.module.ts b/apps/client/src/app/pages/account/account-page.module.ts index cf0f52a0..d583e47c 100644 --- a/apps/client/src/app/pages/account/account-page.module.ts +++ b/apps/client/src/app/pages/account/account-page.module.ts @@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { RouterModule } from '@angular/router'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; +import { GfValueModule } from '@ghostfolio/ui/value'; import { AccountPageRoutingModule } from './account-page-routing.module'; import { AccountPageComponent } from './account-page.component'; @@ -24,6 +25,7 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di FormsModule, GfCreateOrUpdateAccessDialogModule, GfPortfolioAccessTableModule, + GfValueModule, MatButtonModule, MatCardModule, MatDialogModule, diff --git a/apps/client/src/main.ts b/apps/client/src/main.ts index 54e4b175..91523611 100644 --- a/apps/client/src/main.ts +++ b/apps/client/src/main.ts @@ -1,6 +1,7 @@ import { enableProdMode } from '@angular/core'; import { LOCALE_ID } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { locale } from '@ghostfolio/common/config'; import { InfoItem } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -27,7 +28,7 @@ import { environment } from './environments/environment'; platformBrowserDynamic() .bootstrapModule(AppModule, { - providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }] + providers: [{ provide: LOCALE_ID, useValue: locale }] }) .catch((error) => console.error(error)); })(); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 535fa2ef..133dbeef 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -19,7 +19,7 @@ export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`; export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN; export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; -export const locale = 'de-CH'; +export const locale = 'en-US'; export const primaryColorHex = '#36cfcc'; export const primaryColorRgb = { @@ -44,7 +44,6 @@ export const warnColorRgb = { export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND'; -export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const PROPERTY_COUPONS = 'COUPONS'; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index e337493c..2e45d40c 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -2,7 +2,7 @@ import * as currencies from '@dinero.js/currencies'; import { DataSource } from '@prisma/client'; import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; -import { ghostfolioScraperApiSymbolPrefix } from './config'; +import { ghostfolioScraperApiSymbolPrefix, locale } from './config'; export function capitalize(aString: string) { return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); @@ -44,6 +44,49 @@ export function getCssVariable(aCssVariable: string) { ); } +export function getDateFormatString(aLocale?: string) { + const formatObject = new Intl.DateTimeFormat(aLocale).formatToParts( + new Date() + ); + + return formatObject + .map((object) => { + switch (object.type) { + case 'day': + return 'dd'; + case 'month': + return 'MM'; + case 'year': + return 'yyyy'; + default: + return object.value; + } + }) + .join(''); +} + +export function getLocale() { + return navigator.languages?.length + ? navigator.languages[0] + : navigator.language ?? locale; +} + +export function getNumberFormatDecimal(aLocale?: string) { + const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99); + + return formatObject.find((object) => { + return object.type === 'decimal'; + }).value; +} + +export function getNumberFormatGroup(aLocale?: string) { + const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99); + + return formatObject.find((object) => { + return object.type === 'group'; + }).value; +} + export function getTextColor() { const cssVariable = getCssVariable( window.matchMedia('(prefers-color-scheme: dark)').matches diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index b8d8b059..52bc841f 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -20,7 +20,7 @@ import { MatSort } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { Router } from '@angular/router'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; -import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; +import { getDateFormatString } from '@ghostfolio/common/helper'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import Big from 'big.js'; @@ -63,7 +63,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @ViewChild(MatSort) sort: MatSort; public dataSource: MatTableDataSource = new MatTableDataSource(); - public defaultDateFormat = DEFAULT_DATE_FORMAT; + public defaultDateFormat: string; public displayedColumns = []; public endOfToday = endOfToday(); public filters$: Subject = new BehaviorSubject([]); @@ -153,6 +153,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { this.isLoading = true; + this.defaultDateFormat = getDateFormatString(this.locale); + if (this.activities) { this.dataSource = new MatTableDataSource(this.activities); this.dataSource.filterPredicate = (data, filter) => { diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index daa8d72b..08fdb413 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -4,8 +4,8 @@ import { Input, OnChanges } from '@angular/core'; -import { DEFAULT_DATE_FORMAT, locale } from '@ghostfolio/common/config'; -import { format, isDate, parseISO } from 'date-fns'; +import { getLocale } from '@ghostfolio/common/helper'; +import { isDate, parseISO } from 'date-fns'; import { isNumber } from 'lodash'; @Component({ @@ -21,7 +21,7 @@ export class ValueComponent implements OnChanges { @Input() isCurrency = false; @Input() isPercent = false; @Input() label = ''; - @Input() locale = locale; + @Input() locale = getLocale(); @Input() position = ''; @Input() precision: number | undefined; @Input() size: 'large' | 'medium' | 'small' = 'small'; @@ -102,10 +102,13 @@ export class ValueComponent implements OnChanges { try { if (isDate(parseISO(this.value))) { - this.formattedValue = format( - new Date(this.value), - DEFAULT_DATE_FORMAT - ); + this.formattedValue = new Date( + this.value + ).toLocaleDateString(this.locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); } } catch { this.formattedValue = this.value;