Browse Source

Merge remote-tracking branch 'origin/main' into task/7393-value-component-remove-deprecated-loading

pull/7964/head
KenTandrian 2 weeks ago
parent
commit
01ac8e35fa
  1. 1
      .gitignore
  2. 38
      CHANGELOG.md
  3. 5
      apps/api/eslint.config.cjs
  4. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  5. 2
      apps/api/src/app/account/account.service.ts
  6. 2
      apps/api/src/app/activities/activities-filter.dto.ts
  7. 2
      apps/api/src/app/activities/activities.service.ts
  8. 2
      apps/api/src/app/asset/asset.controller.ts
  9. 9
      apps/api/src/app/auth/auth.module.ts
  10. 20
      apps/api/src/app/auth/oidc.helper.spec.ts
  11. 4
      apps/api/src/app/auth/oidc.helper.ts
  12. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  13. 2
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  14. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  15. 2
      apps/api/src/app/export/export.service.ts
  16. 2
      apps/api/src/app/export/get-export.dto.ts
  17. 2
      apps/api/src/app/import/import.service.ts
  18. 2
      apps/api/src/app/info/info.service.ts
  19. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  20. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  21. 2
      apps/api/src/app/portfolio/current-rate.service.ts
  22. 4
      apps/api/src/app/portfolio/portfolio.service.ts
  23. 2
      apps/api/src/app/symbol/symbol.controller.ts
  24. 2
      apps/api/src/app/user/user.controller.ts
  25. 2
      apps/api/src/app/user/user.service.ts
  26. 2
      apps/api/src/dtos/filter.dto.ts
  27. 2
      apps/api/src/helper/activity.helper.ts
  28. 1090
      apps/api/src/helper/object.helper.spec.ts
  29. 2
      apps/api/src/helper/object.helper.ts
  30. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  31. 2
      apps/api/src/models/rule.ts
  32. 2
      apps/api/src/services/benchmark/benchmark.service.ts
  33. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  34. 2
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  35. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  36. 2
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  37. 2
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  38. 2
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  39. 2
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  40. 2
      apps/client/jest.config.ts
  41. 38
      apps/client/src/app/app.component.ts
  42. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  43. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  44. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  45. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  46. 9
      apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts
  47. 24
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  48. 5
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/interfaces/interfaces.ts
  49. 2
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  50. 22
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  51. 11
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  52. 12
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  53. 2
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/interfaces/interfaces.ts
  54. 2
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  55. 29
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  56. 37
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  57. 2
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts
  58. 25
      apps/client/src/app/components/admin-users/admin-users.component.ts
  59. 22
      apps/client/src/app/components/admin-users/admin-users.html
  60. 24
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  61. 2
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  62. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  63. 5
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  64. 30
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  65. 2
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  66. 62
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  67. 36
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  68. 2
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  69. 1
      apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts
  70. 48
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  71. 21
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  72. 3
      apps/client/src/app/interfaces/interfaces.ts
  73. 13
      apps/client/src/app/pages/about/overview/about-overview-page.html
  74. 12
      apps/client/src/app/pages/about/overview/about-overview-page.scss
  75. 2
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  76. 3
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  77. 2
      apps/client/src/app/pages/api/api-page.component.ts
  78. 3
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  79. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/types/activity-type.type.ts
  80. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  81. 3
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts
  82. 47
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  83. 1
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  84. 34
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  85. 88
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  86. 10
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  87. 30
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  88. 2
      apps/client/src/app/services/ics/ics.service.ts
  89. 5
      apps/client/src/app/services/import-activities.service.ts
  90. 28
      apps/client/src/app/util/date.util.ts
  91. 350
      apps/client/src/locales/messages.ca.xlf
  92. 352
      apps/client/src/locales/messages.de.xlf
  93. 352
      apps/client/src/locales/messages.es.xlf
  94. 352
      apps/client/src/locales/messages.fr.xlf
  95. 350
      apps/client/src/locales/messages.it.xlf
  96. 350
      apps/client/src/locales/messages.ja.xlf
  97. 352
      apps/client/src/locales/messages.ko.xlf
  98. 352
      apps/client/src/locales/messages.nl.xlf
  99. 352
      apps/client/src/locales/messages.pl.xlf
  100. 350
      apps/client/src/locales/messages.pt.xlf

1
.gitignore

@ -2,6 +2,7 @@
# compiled output
/out-tsc
/prisma/generated
/tmp
# dependencies

38
CHANGELOG.md

@ -5,17 +5,53 @@ 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
## 3.76.0 - 2026-09-30
### Added
- Added a loading indicator to the platform management of the admin control panel
- Added a loading indicator to the tag management of the admin control panel
- Added a _Refresh_ action to the user detail dialog menu in the admin control panel
- Added the total column to the table of the tag management in the admin control panel
### Changed
- Improved the charts to update their colors if the appearance is set to auto and the operating system switches between light and dark mode
- Extended the emergency fund dialog in the portfolio summary to include the currency suffix
- Extended the user detail dialog in the users section of the admin control panel to include the last request
- Migrated the client to the code generated by the new `prisma-client` generator of _Prisma_
- Improved the language localization for German (`de`)
- Migrated from `lodash` to `lodash-es`
### Fixed
- Fixed the colors of the investment chart component (dark mode)
- Fixed the colors of the charts if the appearance is set to light and the operating system uses dark mode
- Fixed the allocation column of the top holdings of ETFs on the allocations page after a change of the user settings
- Fixed the allocation column of the top holdings of funds other than ETFs on the allocations page
- Fixed the value column of the top holdings of ETFs on the allocations page in the _Presenter View_
- Fixed the projected sustainable retirement income on the _FIRE_ page for a projected total amount of zero (experimental)
- Fixed the sustainable retirement income on the _FIRE_ page for redacted values
- Fixed the emergency fund in the portfolio summary for redacted values
- Fixed the redaction of `valueInBaseCurrency` in the holdings of ETFs in a restricted view
- Fixed the redaction of `comment`, `scraperConfiguration` and `symbolMapping` in the asset profile relation of the activities in a restricted view
- Resolved the data source transformation in the asset profile relation of the activities
## 3.75.0 - 2026-09-28
### Changed
- Extended the net performance on the analysis page to include the dividends (experimental)
- Extended the net performance in the portfolio summary to include the dividends (experimental)
- Improved the get quotes functionality of the _Manual_ service
- Migrated the historical market data editor dialog from `ngModel` to form control
- Migrated the _ESLint_ configuration to the flat config format without `FlatCompat`
- Upgraded `chartjs-chart-treemap` from version `4.2.0` to `4.2.2`
### Fixed
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
- Fixed the discovery of the _OpenID Connect_ (`OIDC`) configuration for issuer URLs with a trailing slash (experimental)
## 3.74.0 - 2026-09-27

5
apps/api/eslint.config.cjs

@ -21,7 +21,10 @@ module.exports = [
{
files: ['**/*.ts', '**/*.tsx'],
// Override or add rules here
rules: {}
rules: {
// TODO: Remove once the API uses the code generated by the new prisma-client generator
'@typescript-eslint/no-restricted-imports': 'off'
}
},
{
files: ['**/*.js', '**/*.jsx'],

2
apps/api/src/app/account-balance/account-balance.service.ts

@ -24,7 +24,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, format, min, parseISO } from 'date-fns';
import { groupBy } from 'lodash';
import { groupBy } from 'lodash-es';
@Injectable()
export class AccountBalanceService {

2
apps/api/src/app/account/account.service.ts

@ -28,7 +28,7 @@ import {
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy, isNil } from 'lodash';
import { groupBy, isNil } from 'lodash-es';
import { CashDetails } from './interfaces/cash-details.interface';

2
apps/api/src/app/activities/activities-filter.dto.ts

@ -5,7 +5,7 @@ import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, Matches } from 'class-validator';
import { isString } from 'lodash';
import { isString } from 'lodash-es';
export class ActivitiesFilterDto extends FilterDto {
@IsEnum(ActivityType, { each: true })

2
apps/api/src/app/activities/activities.service.ts

@ -60,7 +60,7 @@ import {
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { groupBy, uniqBy } from 'lodash';
import { groupBy, uniqBy } from 'lodash-es';
import { randomUUID } from 'node:crypto';
@Injectable()

2
apps/api/src/app/asset/asset.controller.ts

@ -5,7 +5,7 @@ import type { AssetResponse } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { pick } from 'lodash';
import { pick } from 'lodash-es';
@Controller('asset')
export class AssetController {

9
apps/api/src/app/auth/auth.module.ts

@ -22,6 +22,7 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { getOidcDiscoveryUrl } from './oidc.helper';
import { OidcStrategy } from './oidc.strategy';
@Module({
@ -94,9 +95,15 @@ import { OidcStrategy } from './oidc.strategy';
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration`
getOidcDiscoveryUrl(issuer)
);
if (!response.ok) {
throw new Error(
`OIDC discovery request failed with status ${response.status}`
);
}
const config = (await response.json()) as {
authorization_endpoint: string;
token_endpoint: string;

20
apps/api/src/app/auth/oidc.helper.spec.ts

@ -0,0 +1,20 @@
import { getOidcDiscoveryUrl } from './oidc.helper';
describe('getOidcDiscoveryUrl', () => {
it.each([
[
'https://auth.example.com',
'https://auth.example.com/.well-known/openid-configuration'
],
[
'https://auth.example.com/',
'https://auth.example.com/.well-known/openid-configuration'
],
[
'https://auth.example.com/application/o/ghostfolio/',
'https://auth.example.com/application/o/ghostfolio/.well-known/openid-configuration'
]
])('creates the discovery URL for %s', (issuer, expected) => {
expect(getOidcDiscoveryUrl(issuer)).toBe(expected);
});
});

4
apps/api/src/app/auth/oidc.helper.ts

@ -0,0 +1,4 @@
export function getOidcDiscoveryUrl(issuer: string) {
// Remove trailing slashes
return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
}

2
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -29,7 +29,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
import { groupBy } from 'lodash-es';
@Injectable()
export class AssetProfilesService {

2
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -14,7 +14,7 @@ import { DateRange } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
@Injectable()
export class BenchmarksService {

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -40,7 +40,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { addMilliseconds, isBefore } from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty } from 'lodash-es';
@Injectable()
export class GhostfolioService {

2
apps/api/src/app/export/export.service.ts

@ -11,7 +11,7 @@ import {
import { Injectable } from '@nestjs/common';
import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash';
import { groupBy, uniqBy } from 'lodash-es';
@Injectable()
export class ExportService {

2
apps/api/src/app/export/get-export.dto.ts

@ -2,7 +2,7 @@ import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-f
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
import { isString } from 'lodash-es';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()

2
apps/api/src/app/import/import.service.ts

@ -47,7 +47,7 @@ import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { isISIN } from 'class-validator';
import { isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { omit, uniqBy } from 'lodash-es';
import { randomUUID } from 'node:crypto';
import { ImportValidationError } from './errors/import-validation.error';

2
apps/api/src/app/info/info.service.ts

@ -26,7 +26,7 @@ import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns';
import { isNil } from 'lodash';
import { isNil } from 'lodash-es';
@Injectable()
export class InfoService {

2
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -72,7 +72,7 @@ import {
startOfYear,
subDays
} from 'date-fns';
import { groupBy, sortBy, uniqBy } from 'lodash';
import { groupBy, sortBy, uniqBy } from 'lodash-es';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -32,7 +32,7 @@ import {
isBefore,
isThisYear
} from 'date-fns';
import { sum } from 'lodash';
import { sum } from 'lodash-es';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(

2
apps/api/src/app/portfolio/current-rate.service.ts

@ -14,7 +14,7 @@ import {
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { isEmpty, uniqBy } from 'lodash';
import { isEmpty, uniqBy } from 'lodash-es';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';

4
apps/api/src/app/portfolio/portfolio.service.ts

@ -104,7 +104,7 @@ import {
set,
startOfDay
} from 'date-fns';
import { groupBy } from 'lodash';
import { groupBy } from 'lodash-es';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -2140,6 +2140,7 @@ export class PortfolioService {
const {
currentValueInBaseCurrency,
dividendPercentageWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
@ -2236,6 +2237,7 @@ export class PortfolioService {
cash,
currentValueInBaseCurrency,
dateOfFirstActivity,
dividendPercentageWithCurrencyEffect,
excludedAccountsAndActivities,
netPerformance,
netPerformancePercentage,

2
apps/api/src/app/symbol/symbol.controller.ts

@ -25,7 +25,7 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { isValid, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { isEmpty } from 'lodash-es';
import { GetLookupDto } from './get-lookup.dto';
import { SymbolService } from './symbol.service';

2
apps/api/src/app/user/user.controller.ts

@ -53,7 +53,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash';
import { merge, size } from 'lodash-es';
import { UserService } from './user.service';

2
apps/api/src/app/user/user.service.ts

@ -46,7 +46,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns';
import { isNil, without } from 'lodash';
import { isNil, without } from 'lodash-es';
import { createHmac } from 'node:crypto';
@Injectable()

2
apps/api/src/dtos/filter.dto.ts

@ -9,7 +9,7 @@ import {
IsUUID,
MaxLength
} from 'class-validator';
import { isString } from 'lodash';
import { isString } from 'lodash-es';
export class FilterDto {
@IsOptional()

2
apps/api/src/helper/activity.helper.ts

@ -5,7 +5,7 @@ import {
import { Prisma, Type as ActivityType } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { uniqBy } from 'lodash';
import { uniqBy } from 'lodash-es';
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = {
tags: {

1090
apps/api/src/helper/object.helper.spec.ts

File diff suppressed because it is too large

2
apps/api/src/helper/object.helper.ts

@ -1,6 +1,6 @@
import fastRedact from 'fast-redact';
import jsonpath from 'jsonpath';
import { cloneDeep, isObject } from 'lodash';
import { cloneDeep, isObject } from 'lodash-es';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {

2
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -83,6 +83,7 @@ export class TransformDataSourceInResponseInterceptor<
'["filters.dataSource"]',
'activities[*].assetProfile.dataSource',
'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
@ -93,6 +94,7 @@ export class TransformDataSourceInResponseInterceptor<
'items[*].dataSource',
'latestActivities[*].assetProfile.dataSource',
'settings["filters.dataSource"]',
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
]
});

2
apps/api/src/models/rule.ts

@ -7,7 +7,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
import { groupBy } from 'lodash';
import { groupBy } from 'lodash-es';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';

2
apps/api/src/services/benchmark/benchmark.service.ts

@ -23,7 +23,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { addHours, isPast, subDays } from 'date-fns';
import { round, uniqBy } from 'lodash';
import { round, uniqBy } from 'lodash-es';
import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';

2
apps/api/src/services/data-provider/data-provider.service.ts

@ -42,7 +42,7 @@ import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, omit, uniqWith } from 'lodash';
import { groupBy, isEmpty, isNumber, omit, uniqWith } from 'lodash-es';
import ms from 'ms';
import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';

2
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -31,7 +31,7 @@ import {
SymbolProfile
} from '@prisma/client';
import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
@Injectable()
export class EodHistoricalDataService

2
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -47,7 +47,7 @@ import {
isSameDay,
parseISO
} from 'date-fns';
import { isArray, uniqBy } from 'lodash';
import { isArray, uniqBy } from 'lodash-es';
@Injectable()
export class FinancialModelingPrepService

2
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -24,7 +24,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
import { ReasonPhrases, StatusCodes } from 'http-status-codes';
import { uniqBy } from 'lodash';
import { uniqBy } from 'lodash-es';
import YahooFinance from 'yahoo-finance2';
import type { ChartResultArray } from 'yahoo-finance2/modules/chart';
import type {

2
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -26,7 +26,7 @@ import {
isToday,
subDays
} from 'date-fns';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
import ms from 'ms';
import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface';

2
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -33,7 +33,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Job, JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty } from 'lodash-es';
import ms, { StringValue } from 'ms';
@Injectable()

2
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -10,7 +10,7 @@ import {
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { isWeekend } from 'date-fns';
import { round } from 'lodash';
import { round } from 'lodash-es';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()

2
apps/client/jest.config.ts

@ -18,6 +18,6 @@ export default {
}
]
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
transformIgnorePatterns: ['node_modules/(?!.*.mjs$|lodash-es/)'],
preset: '../../jest.preset.js'
};

38
apps/client/src/app/app.component.ts

@ -1,4 +1,8 @@
import { getCssVariable } from '@ghostfolio/common/helper';
import {
getBackgroundColor,
getCssVariable,
isDarkColorScheme
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
InfoItem,
@ -227,7 +231,7 @@ export class GfAppComponent implements OnInit {
: !!this.info?.subscriptionOffer?.coupon ||
!!this.info?.subscriptionOffer?.durationExtension;
this.initializeTheme(this.user?.settings.colorScheme);
this.toggleTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck();
});
@ -259,21 +263,19 @@ export class GfAppComponent implements OnInit {
document.location.href = `/${document.documentElement.lang}`;
}
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
const isDarkTheme = userPreferredColorScheme
? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.toggleTheme(isDarkTheme);
private initializeTheme() {
this.toggleTheme();
// Default chart styles
Chart.defaults.font.family = getCssVariable('--font-family-sans-serif');
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) {
this.toggleTheme(event.matches);
}
});
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (!this.user?.settings.colorScheme) {
this.toggleTheme();
}
});
}
private openHoldingDetailDialog({
@ -344,12 +346,8 @@ export class GfAppComponent implements OnInit {
});
}
private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background'
);
if (isDarkTheme) {
private toggleTheme(userPreferredColorScheme?: ColorScheme) {
if (isDarkColorScheme(userPreferredColorScheme)) {
this.document.body.classList.add('theme-dark');
this.document.body.classList.remove('theme-light');
} else {
@ -359,6 +357,6 @@ export class GfAppComponent implements OnInit {
this.document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', themeColor);
?.setAttribute('content', getBackgroundColor(userPreferredColorScheme));
}
}

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -16,6 +16,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type { Tag } from '@ghostfolio/prisma/browser';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -45,7 +46,6 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
@ -55,7 +55,7 @@ import {
readerOutline,
swapVerticalOutline
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { filter, forkJoin } from 'rxjs';

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -23,6 +23,7 @@
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercentage]="showValuesInPercentage()"

2
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -49,7 +49,7 @@ import {
removeCircleOutline,
timeOutline
} from 'ionicons/icons';
import { get } from 'lodash';
import { get } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({

3
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -10,6 +10,8 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import type { SymbolProfile } from '@ghostfolio/prisma/browser';
import { AssetSubClass, DataSource } from '@ghostfolio/prisma/enums';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n';
@ -48,7 +50,6 @@ import {
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
import { addIcons } from 'ionicons';
import {

9
apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts

@ -1,10 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_COLOR_SCHEME,
DEFAULT_LOCALE
} from '@ghostfolio/common/config';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { AssetProfileIdentifier, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataSource } from '@ghostfolio/prisma/enums';
import { AdminService } from '@ghostfolio/ui/services';
import {
@ -19,7 +17,6 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators';
@ -150,7 +147,7 @@ export class GfAssetProfileDialogHostComponent implements OnDestroy, OnInit {
data: {
dataSource,
symbol,
colorScheme: user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
colorScheme: user?.settings?.colorScheme,
deviceType: this.deviceType(),
locale: user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams,

24
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -29,6 +29,18 @@ import {
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { jsonValidator, validateObjectForForm } from '@ghostfolio/common/utils';
import type {
AssetProfileSplit,
MarketData,
Prisma,
SymbolProfile
} from '@ghostfolio/prisma/browser';
import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource
} from '@ghostfolio/prisma/enums';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -80,16 +92,6 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone';
import {
AssetClass,
AssetProfileSplit,
AssetSubClass,
DataGatheringFrequency,
DataSource,
MarketData,
Prisma,
SymbolProfile
} from '@prisma/client';
import { isUUID } from 'class-validator';
import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
@ -104,7 +106,7 @@ import {
serverOutline,
trashOutline
} from 'ionicons/icons';
import { isBoolean } from 'lodash';
import { isBoolean } from 'lodash-es';
import ms from 'ms';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';

5
apps/client/src/app/components/admin-market-data/asset-profile-dialog/interfaces/interfaces.ts

@ -1,9 +1,8 @@
import { ColorScheme } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DataSource } from '@ghostfolio/prisma/enums';
export interface AssetProfileDialogParams {
colorScheme: ColorScheme;
colorScheme?: ColorScheme;
dataSource: DataSource;
deviceType: string;
locale: string;

2
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts

@ -4,6 +4,7 @@ import {
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource } from '@ghostfolio/prisma/enums';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
@ -31,7 +32,6 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator';
import { switchMap } from 'rxjs';

22
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -48,12 +48,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import {
addMilliseconds,
differenceInSeconds,
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
import { addMilliseconds, formatDistanceToNowStrict } from 'date-fns';
import { addIcons } from 'ionicons';
import {
closeCircleOutline,
@ -187,21 +182,6 @@ export class GfAdminOverviewComponent implements OnInit {
this.fetchAdminData();
}
protected formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now'
: distanceString;
}
return '';
}
protected formatStringValue(aStringValue: StringValue) {
return formatDistanceToNowStrict(
addMilliseconds(new Date(), ms(aStringValue)),

11
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -103,6 +103,17 @@
</table>
</div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"

12
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -3,6 +3,7 @@ import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import type { Platform } from '@ghostfolio/prisma/browser';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -28,7 +29,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Platform } from '@prisma/client';
import { addIcons } from 'ionicons';
import {
createOutline,
@ -36,6 +36,7 @@ import {
trashOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-dialog/interfaces/interfaces';
@ -51,6 +52,7 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
selector: 'gf-admin-platform',
@ -61,6 +63,7 @@ export class GfAdminPlatformComponent implements OnInit {
public readonly locale = input(getLocale());
protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = [
'icon',
'name',
@ -68,12 +71,15 @@ export class GfAdminPlatformComponent implements OnInit {
'accounts',
'actions'
];
protected isLoading = false;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected platforms: Platform[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort);
@ -149,6 +155,8 @@ export class GfAdminPlatformComponent implements OnInit {
}
private fetchPlatforms() {
this.isLoading = true;
this.adminService
.fetchPlatforms()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -162,6 +170,8 @@ export class GfAdminPlatformComponent implements OnInit {
this.dataService.updateInfo();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

2
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/interfaces/interfaces.ts

@ -1,4 +1,4 @@
import { Platform } from '@prisma/client';
import type { Platform } from '@ghostfolio/prisma/browser';
export interface CreateOrUpdatePlatformDialogParams {
platform?: Platform;

2
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -42,7 +42,7 @@ import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { ellipsisHorizontal, trashOutline } from 'ionicons/icons';
import { get } from 'lodash';
import { get } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, filter, of } from 'rxjs';

29
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -77,6 +77,24 @@
</td>
</ng-container>
<ng-container matColumnDef="total">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="totalCount"
>
<ng-container i18n>Total</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale()"
[value]="getTotalCount(element)"
/>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -123,6 +141,17 @@
</table>
</div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"

37
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -7,6 +7,7 @@ import {
getLowercase,
isSystemTag
} from '@ghostfolio/common/helper';
import { TagWithAccountAndActivityCount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
@ -32,7 +33,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { addIcons } from 'ionicons';
import {
createOutline,
@ -40,6 +40,7 @@ import {
trashOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/interfaces/interfaces';
@ -54,6 +55,7 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
selector: 'gf-admin-tag',
@ -63,22 +65,28 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
export class GfAdminTagComponent implements OnInit {
public readonly locale = input(getLocale());
protected dataSource = new MatTableDataSource<Tag>();
protected dataSource =
new MatTableDataSource<TagWithAccountAndActivityCount>();
protected readonly displayedColumns = [
'name',
'userId',
'accounts',
'activities',
'total',
'actions'
];
protected isLoading = false;
protected readonly isSystemTag = isSystemTag;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[];
protected tags: TagWithAccountAndActivityCount[];
protected readonly translate = translate;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort);
@ -120,6 +128,13 @@ export class GfAdminTagComponent implements OnInit {
this.fetchTags();
}
protected getTotalCount({
accountCount,
activityCount
}: TagWithAccountAndActivityCount) {
return accountCount + activityCount;
}
protected onDeleteTag(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
@ -130,7 +145,7 @@ export class GfAdminTagComponent implements OnInit {
});
}
protected onUpdateTag({ id }: Tag) {
protected onUpdateTag({ id }: TagWithAccountAndActivityCount) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
@ -153,6 +168,8 @@ export class GfAdminTagComponent implements OnInit {
}
private fetchTags() {
this.isLoading = true;
this.dataService
.fetchTags()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -164,13 +181,19 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = (tag, path) => {
return path === 'name'
? translate(tag.name).toLocaleLowerCase()
: (getLowercase(tag, path) as number | string);
if (path === 'name') {
return translate(tag.name).toLocaleLowerCase();
} else if (path === 'totalCount') {
return this.getTotalCount(tag);
}
return getLowercase(tag, path) as number | string;
};
this.dataService.updateInfo();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

2
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts

@ -1,4 +1,4 @@
import { Tag } from '@prisma/client';
import type { Tag } from '@ghostfolio/prisma/browser';
export interface CreateOrUpdateTagDialogParams {
tag?: Pick<Tag, 'id' | 'name'>;

25
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -5,12 +5,12 @@ import {
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { formatRelativeTime } from '@ghostfolio/client/util/date.util';
import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
canDeleteUser,
getCountryName,
getDateFnsLocale,
getDateFormatString,
getEmojiFlag
} from '@ghostfolio/common/helper';
@ -49,11 +49,6 @@ import {
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import {
differenceInSeconds,
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
import { addIcons } from 'ionicons';
import {
contractOutline,
@ -93,6 +88,7 @@ export class GfAdminUsersComponent implements OnInit {
protected defaultDateFormat: string;
protected displayedColumns: string[] = [];
protected readonly canDeleteUser = canDeleteUser;
protected readonly formatRelativeTime = formatRelativeTime;
protected readonly getCountryName = getCountryName;
protected readonly getEmojiFlag = getEmojiFlag;
protected hasPermissionForSubscription: boolean;
@ -209,22 +205,6 @@ export class GfAdminUsersComponent implements OnInit {
});
}
protected formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true,
locale: getDateFnsLocale(this.user?.settings?.language)
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? $localize`just now`
: distanceString;
}
return '';
}
protected onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
@ -332,6 +312,7 @@ export class GfAdminUsersComponent implements OnInit {
currentUserId: this.user?.id,
deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription,
language: this.user?.settings?.language,
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE,
userId: aUserId
} satisfies UserDetailDialogParams,

22
apps/client/src/app/components/admin-users/admin-users.html

@ -32,7 +32,10 @@
class="ml-1"
i18n-title
title="Expires {{
formatDistanceToNow(element.subscription.expiresAt)
formatRelativeTime({
date: element.subscription.expiresAt,
language: user?.settings?.language
})
}} ({{
element.subscription.expiresAt | date: defaultDateFormat
}})"
@ -79,7 +82,12 @@
class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell
>
{{ formatDistanceToNow(element.createdAt) }}
{{
formatRelativeTime({
date: element.createdAt,
language: user?.settings?.language
})
}}
</td>
</ng-container>
@ -175,17 +183,21 @@
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
i18n
mat-header-cell
>
Last Request
<ng-container i18n>Last Request</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell
>
{{ formatDistanceToNow(element.lastActivity) }}
{{
formatRelativeTime({
date: element.lastActivity,
language: user?.settings?.language
})
}}
</td>
</ng-container>
}

24
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -12,8 +12,10 @@ import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import type { SymbolProfile } from '@ghostfolio/prisma/browser';
import {
getTimeSeriesTooltipOptions,
onPrefersColorSchemeChange,
registerChartConfiguration
} from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -26,13 +28,13 @@ import {
OnChanges,
OnDestroy,
output,
SimpleChanges,
viewChild
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { SymbolProfile } from '@prisma/client';
import {
Chart,
ChartData,
@ -68,7 +70,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmark = input<Partial<SymbolProfile>>();
public readonly benchmarkDataItems = input<LineChartItem[]>([]);
public readonly benchmarks = input<Partial<SymbolProfile>[]>();
public readonly colorScheme = input.required<ColorScheme>();
public readonly colorScheme = input<ColorScheme>();
public readonly isLoading = input<boolean>();
public readonly locale = input(getLocale());
public readonly performanceDataItems = input.required<LineChartItem[]>();
@ -76,7 +78,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmarkChanged = output<string>();
protected chart: Chart<'line'>;
protected chart?: Chart<'line'>;
protected hasPermissionToAccessAdminControl: boolean;
protected readonly routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
@ -96,10 +98,24 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
registerChartConfiguration();
onPrefersColorSchemeChange(() => {
if (this.chart && !this.colorScheme()) {
this.chart.destroy();
this.chart = undefined;
this.initialize();
}
});
addIcons({ arrowForwardOutline });
}
public ngOnChanges() {
public ngOnChanges(changes: SimpleChanges) {
if (changes.colorScheme && this.chart) {
this.chart.destroy();
this.chart = undefined;
}
this.hasPermissionToAccessAdminControl = hasPermission(
this.user()?.permissions,
permissions.accessAdminControl

2
apps/client/src/app/components/data-provider-status/data-provider-status.component.ts

@ -1,3 +1,4 @@
import type { DataSource } from '@ghostfolio/prisma/enums';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
@ -9,7 +10,6 @@ import {
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, map, type Observable, of } from 'rxjs';

4
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -25,6 +25,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import type { MarketData, Tag } from '@ghostfolio/prisma/browser';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
@ -69,7 +70,6 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
@ -82,7 +82,7 @@ import {
swapVerticalOutline,
walletOutline
} from 'ionicons/icons';
import { isNumber, round, uniqBy } from 'lodash';
import { isNumber, round, uniqBy } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { filter, switchMap } from 'rxjs/operators';

5
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -1,10 +1,9 @@
import { ColorScheme } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DataSource } from '@ghostfolio/prisma/enums';
export interface HoldingDetailDialogParams {
baseCurrency: string;
colorScheme: ColorScheme;
colorScheme?: ColorScheme;
dataSource: DataSource;
deviceType: string;
hasPermissionToAccessAdminControl: boolean;

30
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -14,6 +14,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import {
getTimeSeriesTooltipOptions,
onPrefersColorSchemeChange,
registerChartConfiguration
} from '@ghostfolio/ui/chart';
@ -24,6 +25,7 @@ import {
Input,
OnChanges,
OnDestroy,
SimpleChanges,
viewChild
} from '@angular/core';
import {
@ -55,19 +57,19 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() public benchmarkDataItems: InvestmentItem[] = [];
@Input() public benchmarkDataLabel = '';
@Input() public colorScheme: ColorScheme;
@Input() public currency: string;
@Input() public colorScheme?: ColorScheme;
@Input() public currency?: string;
@Input() public groupBy: GroupBy;
@Input() public historicalDataItems: LineChartItem[] = [];
@Input() public isInPercentage = false;
@Input() public isInPercentage?: boolean = false;
@Input() public isLoading = false;
@Input() public locale = getLocale();
@Input() public savingsRate = 0;
@Input() public locale?: string = getLocale();
@Input() public savingsRate?: number = 0;
private readonly chartCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');
private chart: Chart<'bar' | 'line'>;
private chart?: Chart<'bar' | 'line'>;
private investments: InvestmentItem[];
private values: LineChartItem[];
@ -84,9 +86,23 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
);
registerChartConfiguration();
onPrefersColorSchemeChange(() => {
if (this.chart && !this.colorScheme) {
this.chart.destroy();
this.chart = undefined;
this.initialize();
}
});
}
public ngOnChanges() {
public ngOnChanges(changes: SimpleChanges) {
if (changes.colorScheme && this.chart) {
this.chart.destroy();
this.chart = undefined;
}
if (this.benchmarkDataItems && this.historicalDataItems) {
this.initialize();
}

2
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -23,7 +23,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { CountUp } from 'countup.js';
import { addIcons } from 'ionicons';
import { timeOutline } from 'ionicons/icons';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({

62
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -98,10 +98,29 @@
/>
</div>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="d-flex justify-content-end">
@if (isNumber(summary?.dividendInBaseCurrency)) {
<span class="mr-1">+</span>
}
<gf-value
class="justify-content-end"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
</div>
</div>
}
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Fees</div>
<div class="d-flex justify-content-end">
@if (summary?.fees || summary?.fees === 0) {
@if (isNumber(summary?.fees)) {
<span class="mr-1">-</span>
}
<gf-value
@ -128,7 +147,7 @@
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.netPerformanceWithCurrencyEffect"
[value]="netPerformanceWithCurrencyEffect"
/>
</div>
</div>
@ -154,7 +173,7 @@
[isLoading]="isLoading"
[isPercent]="true"
[locale]="locale"
[value]="summary?.netPerformancePercentageWithCurrencyEffect"
[value]="netPerformancePercentageWithCurrencyEffect"
/>
</div>
</div>
@ -367,10 +386,7 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end">
@if (
summary?.liabilitiesInBaseCurrency ||
summary?.liabilitiesInBaseCurrency === 0
) {
@if (isNumber(summary?.liabilitiesInBaseCurrency)) {
<span class="mr-1">-</span>
}
<gf-value
@ -470,7 +486,9 @@
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.emergencyFund?.total"
[value]="
summary?.emergencyFund === null ? null : summary?.emergencyFund?.total
"
/>
</div>
</div>
@ -488,18 +506,20 @@
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
@if (!user?.settings?.isExperimentalFeatures) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
</div>
</div>
</div>
}
</div>

36
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -23,7 +23,7 @@ import {
ellipsisHorizontalCircleOutline,
informationCircleOutline
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
@ -55,6 +55,7 @@ export class GfPortfolioSummaryComponent implements OnChanges {
'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
);
protected readonly isNumber = isNumber;
protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected isCashExpanded = false;
@ -136,6 +137,36 @@ export class GfPortfolioSummaryComponent implements OnChanges {
);
}
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformancePercentageWithCurrencyEffect)
) {
return this.summary?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformancePercentageWithCurrencyEffect +
(this.summary.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformanceWithCurrencyEffect)
) {
return this.summary?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformanceWithCurrencyEffect +
this.summary.dividendInBaseCurrency
);
}
public ngOnChanges() {
if (this.summary) {
if (
@ -170,7 +201,8 @@ export class GfPortfolioSummaryComponent implements OnChanges {
},
confirmLabel: $localize`Save`,
defaultValue: this.summary.emergencyFund?.total?.toString() ?? '0',
title: $localize`Please set the amount of your emergency fund.`
title: $localize`Please set the amount of your emergency fund.`,
valueSuffix: this.baseCurrency
});
}

2
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -15,6 +15,7 @@ import {
} from '@ghostfolio/common/scopes';
import { AccessLevel, AccountWithPlatform } from '@ghostfolio/common/types';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { AccessType } from '@ghostfolio/prisma/enums';
import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
import { NotificationService } from '@ghostfolio/ui/notifications';
import {
@ -57,7 +58,6 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { IonIcon } from '@ionic/angular/standalone';
import { AccessType } from '@prisma/client';
import { addDays, endOfDay, isValid, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';

1
apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts

@ -2,6 +2,7 @@ export interface UserDetailDialogParams {
currentUserId: string;
deviceType: string;
hasPermissionForSubscription: boolean;
language?: string;
locale: string;
userId: string;
}

48
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,9 +1,11 @@
import { formatRelativeTime } from '@ghostfolio/client/util/date.util';
import {
canDeleteUser,
getCountryName,
getSum
} from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import type { Subscription } from '@ghostfolio/prisma/browser';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -23,7 +25,6 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone';
import { Subscription } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns';
import { addIcons } from 'ionicons';
@ -55,6 +56,7 @@ import {
export class GfUserDetailDialogComponent implements OnInit {
protected readonly baseCurrency: string;
protected readonly canDeleteUser = canDeleteUser;
protected readonly formatRelativeTime = formatRelativeTime;
protected readonly getCountryName = getCountryName;
protected isLoading = true;
@ -91,25 +93,7 @@ export class GfUserDetailDialogComponent implements OnInit {
}
public ngOnInit() {
this.adminService
.fetchUserById(this.data.userId)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError(() => {
this.dialogRef.close();
return EMPTY;
})
)
.subscribe((user) => {
this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
this.initialize();
}
protected deleteUser() {
@ -141,6 +125,30 @@ export class GfUserDetailDialogComponent implements OnInit {
: $localize`Coupon`;
}
protected initialize() {
this.isLoading = true;
this.adminService
.fetchUserById(this.data.userId)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError(() => {
this.dialogRef.close();
return EMPTY;
})
)
.subscribe((user) => {
this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
protected onClose() {
this.dialogRef.close();
}

21
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -13,6 +13,10 @@
class="no-max-width"
xPosition="before"
>
<button mat-menu-item type="button" (click)="initialize()">
<ng-container i18n>Refresh</ng-container>
</button>
<hr class="m-0" />
<button
mat-menu-item
type="button"
@ -150,6 +154,23 @@
</div>
</div>
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isLoading]="isLoading"
[value]="
formatRelativeTime({
date: user?.lastActivity,
language: data.language
}) || '-'
"
>Last Request</gf-value
>
</div>
</div>
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">

3
apps/client/src/app/interfaces/interfaces.ts

@ -1,5 +1,6 @@
import type { DataSource } from '@ghostfolio/prisma/enums';
import type { Params } from '@angular/router';
import type { DataSource } from '@prisma/client';
export interface GfAppQueryParams extends Params {
dataSource?: DataSource;

13
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -219,12 +219,15 @@
>
<img
alt="TestMu AI Logo"
class="sponsor-logo-dark"
height="32"
[src]="
user?.settings?.colorScheme === 'DARK'
? '../assets/images/sponsors/logo-testmu-light.svg'
: '../assets/images/sponsors/logo-testmu-dark.svg'
"
src="../assets/images/sponsors/logo-testmu-dark.svg"
/>
<img
alt="TestMu AI Logo"
class="sponsor-logo-light"
height="32"
src="../assets/images/sponsors/logo-testmu-light.svg"
/>
</a>
</div>

12
apps/client/src/app/pages/about/overview/about-overview-page.scss

@ -22,6 +22,10 @@
width: 10rem;
}
}
.sponsor-logo-light {
display: none;
}
}
:host-context(.theme-dark) {
@ -33,4 +37,12 @@
opacity: 1;
}
}
.sponsor-logo-dark {
display: none;
}
.sponsor-logo-light {
display: inline;
}
}

2
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -8,6 +8,7 @@ import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import type { Platform, Tag } from '@ghostfolio/prisma/browser';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
@ -39,7 +40,6 @@ import {
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Platform, Tag } from '@prisma/client';
import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';

3
apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts

@ -1,7 +1,6 @@
import { User } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
import type { Tag } from '@ghostfolio/prisma/browser';
export interface CreateOrUpdateAccountDialogParams {
account: Omit<

2
apps/client/src/app/pages/api/api-page.component.ts

@ -32,7 +32,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { format, startOfYear } from 'date-fns';
import { isObject } from 'lodash';
import { isObject } from 'lodash-es';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, map, Observable, of, OperatorFunction } from 'rxjs';

3
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -16,6 +16,8 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import type { Tag } from '@ghostfolio/prisma/browser';
import { AssetClass, Type } from '@ghostfolio/prisma/enums';
import { GfAccountSelectorComponent } from '@ghostfolio/ui/account-selector';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services';
@ -50,7 +52,6 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { IonIcon } from '@ionic/angular/standalone';
import { AssetClass, Tag, Type } from '@prisma/client';
import { isAfter, isToday } from 'date-fns';
import { addIcons } from 'ionicons';
import { calendarClearOutline, refreshOutline } from 'ionicons/icons';

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/types/activity-type.type.ts

@ -1,4 +1,4 @@
import { Type } from '@prisma/client';
import { Type } from '@ghostfolio/prisma/enums';
export const ActivityType = {
...Type,

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -8,6 +8,7 @@ import {
CreateTagDto
} from '@ghostfolio/common/dtos';
import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass } from '@ghostfolio/prisma/enums';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
@ -48,10 +49,9 @@ import { SortDirection } from '@angular/material/sort';
import { MatStepper, MatStepperModule } from '@angular/material/stepper';
import { MatTableDataSource } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone';
import { AssetClass } from '@prisma/client';
import { addIcons } from 'ionicons';
import { cloudUploadOutline, warningOutline } from 'ionicons/icons';
import { isArray, sortBy } from 'lodash';
import { isArray, sortBy } from 'lodash-es';
import ms from 'ms';
import { ImportStep } from './enums/import-step';

3
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts

@ -1,6 +1,5 @@
import { User } from '@ghostfolio/common/interfaces';
import { Type } from '@prisma/client';
import { Type } from '@ghostfolio/prisma/enums';
export interface ImportActivitiesDialogParams {
activityTypes?: Type[];

47
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -22,6 +22,12 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { MarketAdvanced } from '@ghostfolio/common/types';
import type { Account, Platform } from '@ghostfolio/prisma/browser';
import {
AssetClass,
AssetSubClass,
DataSource
} from '@ghostfolio/prisma/enums';
import { translate } from '@ghostfolio/ui/i18n';
import {
GfPortfolioProportionChartComponent,
@ -47,14 +53,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { ActivatedRoute, Router } from '@angular/router';
import {
Account,
AssetClass,
AssetSubClass,
DataSource,
Platform
} from '@prisma/client';
import { isNumber } from 'lodash';
import { isNumber } from 'lodash-es';
import { DeviceDetectorService } from 'ngx-device-detector';
import { filter, switchMap, tap } from 'rxjs';
@ -131,15 +130,10 @@ export class GfAllocationsPageComponent implements OnInit {
value: number;
};
};
protected topHoldings: HoldingWithParents[];
protected topHoldings: HoldingWithParents[] | undefined;
protected readonly UNKNOWN_KEY = UNKNOWN_KEY;
protected user: User;
private topHoldingsMap: {
[name: string]: { name: string; value: number };
};
private totalValueInEtf = 0;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
@ -366,7 +360,7 @@ export class GfAllocationsPageComponent implements OnInit {
value: 0
}
};
this.topHoldingsMap = {};
this.topHoldings = undefined;
}
private initializeAllocationsData() {
@ -389,7 +383,12 @@ export class GfAllocationsPageComponent implements OnInit {
};
}
const topHoldingsMap: {
[name: string]: { name: string; value: number };
} = {};
let totalValueExcludingCashPositions = 0;
let totalValueInFunds = 0;
for (const position of this.portfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(
@ -465,6 +464,8 @@ export class GfAllocationsPageComponent implements OnInit {
}
if (position.assetProfile.holdings.length > 0) {
totalValueInFunds += this.holdings[assetProfileIdentifier].value;
for (const {
allocationInPercentage,
name,
@ -475,12 +476,12 @@ export class GfAllocationsPageComponent implements OnInit {
? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName];
const holdingData = topHoldingsMap[normalizedAssetName];
if (holdingData) {
holdingData.value += value;
} else {
this.topHoldingsMap[normalizedAssetName] = {
topHoldingsMap[normalizedAssetName] = {
name,
value
};
@ -515,10 +516,6 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') {
this.totalValueInEtf += this.holdings[assetProfileIdentifier].value;
}
const symbol = position.assetProfile.symbol;
const value = this.extractValue(position);
@ -583,7 +580,7 @@ export class GfAllocationsPageComponent implements OnInit {
};
}
this.topHoldings = Object.values(this.topHoldingsMap)
this.topHoldings = Object.values(topHoldingsMap)
.map(({ name, value }): HoldingWithParents => {
if (this.showValuesInPercentage()) {
return {
@ -595,7 +592,7 @@ export class GfAllocationsPageComponent implements OnInit {
return {
name,
allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
totalValueInFunds > 0 ? value / totalValueInFunds : 0,
parents: this.portfolioDetails.holdings
.map((holding) => {
if (holding.assetProfile.holdings.length > 0) {
@ -612,7 +609,9 @@ export class GfAllocationsPageComponent implements OnInit {
isNumber(currentParentHolding.valueInBaseCurrency)
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
value > 0
? currentParentHolding.valueInBaseCurrency / value
: 0,
name: holding.assetProfile.name ?? '',
position: holding,
symbol: holding.assetProfile.symbol,

1
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -343,6 +343,7 @@
<mat-card-content>
<gf-top-holdings
[baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToShowValues]="!showValuesInPercentage()"
[locale]="user?.settings?.locale"
[pageSize]="10"
[topHoldings]="topHoldings"

34
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -22,6 +22,7 @@ import type {
ToggleOption
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import type { SymbolProfile } from '@ghostfolio/prisma/browser';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
@ -48,10 +49,9 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { SymbolProfile } from '@prisma/client';
import { addIcons } from 'ionicons';
import { copyOutline, ellipsisVertical } from 'ionicons/icons';
import { isNumber, keyBy, sortBy, union } from 'lodash';
import { isNumber, keyBy, sortBy, union } from 'lodash-es';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -133,6 +133,36 @@ export class GfAnalysisPageComponent implements OnInit {
addIcons({ copyOutline, ellipsisVertical });
}
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformancePercentageWithCurrencyEffect)
) {
return this.performance?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformancePercentageWithCurrencyEffect +
(this.performance.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformanceWithCurrencyEffect)
) {
return this.performance?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformanceWithCurrencyEffect +
this.performance.dividendInBaseCurrency
);
}
get savingsRate() {
if (!this.savingsRatePerMonth) {
return undefined;

88
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -187,6 +187,45 @@
/>
</div>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>Absolute Dividend Performance</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex mb-3 ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/>
</div>
</div>
}
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
@ -247,7 +286,7 @@
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.netPerformanceWithCurrencyEffect"
[value]="netPerformanceWithCurrencyEffect"
/>
</div>
</div>
@ -273,52 +312,10 @@
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="
performance?.netPerformancePercentageWithCurrencyEffect
"
[value]="netPerformancePercentageWithCurrencyEffect"
/>
</div>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div><hr /></div>
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>Absolute Dividend Performance</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/>
</div>
</div>
}
</mat-card-content>
</mat-card>
</div>
@ -446,6 +443,7 @@
class="h-100"
[benchmarkDataItems]="investments"
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="performanceDataItems"
[isInPercentage]="showValuesInPercentage()"
@ -502,6 +500,7 @@
class="h-100"
[benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel"
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="showValuesInPercentage()"
@ -537,6 +536,7 @@
class="h-100"
[benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel"
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="showValuesInPercentage()"

10
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -8,6 +8,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
@ -27,6 +28,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { Big } from 'big.js';
import { isFinite, isNumber } from 'lodash-es';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -52,6 +54,7 @@ export class GfFirePageComponent implements OnInit {
protected fireWealth: FireWealth;
protected hasImpersonationId: boolean;
protected hasPermissionToShowValues: boolean;
protected hasPermissionToUpdateUserSettings: boolean;
protected isLoading = false;
protected retirementDate: Date;
@ -141,6 +144,10 @@ export class GfFirePageComponent implements OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToShowValues =
hasScope(this.user.scopes, scopes.portfolioReadValues) &&
!this.user.settings.isRestrictedView;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === SubscriptionType.Basic
? false
@ -256,7 +263,8 @@ export class GfFirePageComponent implements OnInit {
private calculateWithdrawalRatesProjected() {
if (
this.fireWealth &&
this.projectedTotalAmount &&
isNumber(this.projectedTotalAmount) &&
isFinite(this.projectedTotalAmount) &&
this.user?.settings?.safeWithdrawalRate
) {
this.withdrawalRatePerYearProjected = new Big(

30
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -73,7 +73,11 @@
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear?.toNumber()"
[value]="
hasPermissionToShowValues
? withdrawalRatePerYear?.toNumber()
: null
"
/>
per year</span
>
@ -84,7 +88,11 @@
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth?.toNumber()"
[value]="
hasPermissionToShowValues
? withdrawalRatePerMonth?.toNumber()
: null
"
/>
per month</span
>, based on your total assets of
@ -94,7 +102,11 @@
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="fireWealth?.today.valueInBaseCurrency"
[value]="
hasPermissionToShowValues
? fireWealth?.today.valueInBaseCurrency
: null
"
/></span>
and a safe withdrawal rate (SWR) of</ng-container
>
@ -137,7 +149,11 @@
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYearProjected?.toNumber()"
[value]="
hasPermissionToShowValues
? withdrawalRatePerYearProjected?.toNumber()
: null
"
/>
per year</span
>
@ -148,7 +164,11 @@
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonthProjected?.toNumber()"
[value]="
hasPermissionToShowValues
? withdrawalRatePerMonthProjected?.toNumber()
: null
"
/>
per month</span
>, assuming a

2
apps/client/src/app/services/ics/ics.service.ts

@ -1,8 +1,8 @@
import { capitalize } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import { Type } from '@ghostfolio/prisma/enums';
import { Service } from '@angular/core';
import { Type } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@Service()

5
apps/client/src/app/services/import-activities.service.ts

@ -11,11 +11,12 @@ import {
parseDate as parseDateHelper
} from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import type { Account } from '@ghostfolio/prisma/browser';
import { DataSource, Type as ActivityType } from '@ghostfolio/prisma/enums';
import { HttpClient } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite, isNumber, isString } from 'lodash';
import { isFinite, isNumber, isString } from 'lodash-es';
import { parse as csvToJson } from 'papaparse';
import { firstValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';

28
apps/client/src/app/util/date.util.ts

@ -0,0 +1,28 @@
import { getDateFnsLocale } from '@ghostfolio/common/helper';
import {
differenceInSeconds,
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
export function formatRelativeTime({
date,
language
}: {
date: string;
language?: string;
}) {
if (date) {
const distanceString = formatDistanceToNowStrict(parseISO(date), {
addSuffix: true,
locale: getDateFnsLocale(language)
});
return Math.abs(differenceInSeconds(parseISO(date), new Date())) < 60
? $localize`just now`
: distanceString;
}
return '';
}

350
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

350
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

350
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

352
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

350
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save