Browse Source

Merge remote-tracking branch 'origin/main' into task/stats-gathering-tag-service-type-safety

pull/7328/head
KenTandrian 5 days ago
parent
commit
79e44508e5
  1. 41
      CHANGELOG.md
  2. 2
      README.md
  3. 3
      apps/api/src/app/account-balance/account-balance.module.ts
  4. 2
      apps/api/src/app/account/account.module.ts
  5. 35
      apps/api/src/app/account/account.service.ts
  6. 13
      apps/api/src/app/activities/activities.controller.ts
  7. 2
      apps/api/src/app/activities/activities.module.ts
  8. 46
      apps/api/src/app/activities/activities.service.ts
  9. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  10. 2
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  11. 2
      apps/api/src/app/endpoints/public/public.module.ts
  12. 9
      apps/api/src/app/logo/get-logo.dto.ts
  13. 3
      apps/api/src/app/logo/logo.controller.ts
  14. 2
      apps/api/src/app/logo/logo.service.ts
  15. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  16. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  17. 76
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  18. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  19. 2
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  20. 38
      apps/api/src/middlewares/html-template.middleware.ts
  21. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  22. 14
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  23. 38
      apps/api/src/services/tag/tag.service.ts
  24. 7
      apps/client/src/app/app.component.ts
  25. 47
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  26. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  27. 4
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  28. 2
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  29. 12
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  30. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  31. 9
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  32. 67
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  33. 7
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  34. 4
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  35. 10
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  36. 5
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  37. 13
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  38. 6
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  39. 3
      apps/client/src/app/pages/about/changelog/changelog-page.component.ts
  40. 3
      apps/client/src/app/pages/about/license/license-page.component.ts
  41. 3
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts
  42. 6
      apps/client/src/app/pages/about/overview/about-overview-page.component.ts
  43. 3
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts
  44. 3
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts
  45. 20
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  46. 9
      apps/client/src/app/pages/api/api-page.component.ts
  47. 8
      apps/client/src/app/pages/auth/auth-page.component.ts
  48. 3
      apps/client/src/app/pages/demo/demo-page.component.ts
  49. 6
      apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts
  50. 6
      apps/client/src/app/pages/faq/saas/saas-page.component.ts
  51. 7
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts
  52. 12
      apps/client/src/app/pages/features/features-page.component.ts
  53. 3
      apps/client/src/app/pages/i18n/i18n-page.component.ts
  54. 3
      apps/client/src/app/pages/landing/landing-page.component.ts
  55. 3
      apps/client/src/app/pages/markets/markets-page.component.ts
  56. 6
      apps/client/src/app/pages/open/open-page.component.ts
  57. 141
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  58. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  59. 29
      apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts
  60. 169
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  61. 1
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts
  62. 7
      apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts
  63. 14
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  64. 6
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  65. 2
      apps/client/src/app/pages/public/public-page.component.ts
  66. 2
      apps/client/src/app/pages/register/register-page.component.ts
  67. 3
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts
  68. 3
      apps/client/src/app/pages/resources/guides/resources-guides.component.ts
  69. 3
      apps/client/src/app/pages/resources/markets/resources-markets.component.ts
  70. 3
      apps/client/src/app/pages/resources/overview/resources-overview.component.ts
  71. 3
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts
  72. 4
      apps/client/src/app/pages/webauthn/webauthn-page.component.ts
  73. 2
      libs/common/src/lib/config.ts
  74. 3
      libs/common/src/lib/dtos/create-order.dto.ts
  75. 3
      libs/common/src/lib/dtos/update-order.dto.ts
  76. 2
      libs/common/src/lib/routes/interfaces/internal-route.interface.ts
  77. 21
      libs/common/src/lib/routes/routes.ts
  78. 18
      libs/ui/src/lib/activities-table/activities-table.component.html
  79. 33
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  80. 35
      libs/ui/src/lib/activities-table/activities-table.component.ts
  81. 28
      libs/ui/src/lib/assistant/assistant.component.ts
  82. 6
      libs/ui/src/lib/benchmark/benchmark.component.html
  83. 2
      libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts
  84. 2
      libs/ui/src/lib/fab/fab.component.html
  85. 3
      libs/ui/src/lib/fab/fab.component.ts
  86. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  87. 4
      package-lock.json
  88. 2
      package.json

41
CHANGELOG.md

@ -5,6 +5,47 @@ 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
### Changed
- Migrated the clone, create and edit activity dialogs to dedicated routes
- Improved the language localization in the tag management of the admin control panel
### Fixed
- Fixed the missing validation of the tags when creating or updating an activity
- Fixed the missing validation of the tags when updating the tags of a holding
- Fixed an issue where the tags of an activity were lost if updating the activity failed
- Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation
- Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units
- Fixed an issue with removing a linked account from a buy, sell or dividend activity
## 3.27.0 - 2026-07-15
### Changed
- Hardened the validation of the URL in the logo endpoint
- Set the change detection strategy to `OnPush` in the about pages
- Set the change detection strategy to `OnPush` in the accounts page
- Set the change detection strategy to `OnPush` in the demo page
- Set the change detection strategy to `OnPush` in the features page
- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) pages
- Set the change detection strategy to `OnPush` in the landing page
- Set the change detection strategy to `OnPush` in the markets page
- Set the change detection strategy to `OnPush` in the _Open Startup_ (`/open`) page
- Set the change detection strategy to `OnPush` in the pricing page
- Set the change detection strategy to `OnPush` in the public page
- Set the change detection strategy to `OnPush` in the registration page
- Set the change detection strategy to `OnPush` in the resources pages
### Fixed
- Fixed an issue where the symbol was not selected when cloning an activity
- Resolved a startup error in data gathering caused by uninitialized data provider mappings
- Improved the error handling in the `HtmlTemplateMiddleware`
- Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service
## 3.26.0 - 2026-07-14
### Added

2
README.md

@ -77,6 +77,8 @@ The frontend is built with [Angular](https://angular.dev) and uses [Angular Mate
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
Find answers to commonly asked questions about self-hosting Ghostfolio in our [Frequently Asked Questions (FAQ)](https://ghostfol.io/en/faq/self-hosting) section.
<div align="center">
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)

3
apps/api/src/app/account-balance/account-balance.module.ts

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -10,7 +11,7 @@ import { AccountBalanceService } from './account-balance.service';
@Module({
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
imports: [ExchangeRateDataModule, PrismaModule, TagModule],
providers: [AccountBalanceService, AccountService]
})
export class AccountBalanceModule {}

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

@ -7,6 +7,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -25,6 +26,7 @@ import { AccountService } from './account.service';
PortfolioModule,
PrismaModule,
RedactValuesInResponseModule,
TagModule,
UserModule
],
providers: [AccountService]

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

@ -3,10 +3,11 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
Account,
@ -19,7 +20,6 @@ import {
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -30,7 +30,8 @@ export class AccountService {
private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly tagService: TagService
) {}
public async account({
@ -127,7 +128,7 @@ export class AccountService {
aUserId: string,
tagIds?: string[]
): Promise<Account> {
await this.validateTagIds(tagIds, aUserId);
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
const account = await this.prismaService.account.create({
data: {
@ -259,15 +260,15 @@ export class AccountService {
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
where: Prisma.AccountWhereUniqueInput;
},
aUserId: string,
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.validateTagIds(tagIds, aUserId);
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
const account = await this.prismaService.account.update({
data: {
@ -340,26 +341,4 @@ export class AccountService {
});
}
}
private async validateTagIds(tagIds: string[], userId: string) {
if (!tagIds?.length) {
return;
}
const uniqueTagIds = Array.from(new Set(tagIds));
const tagsCount = await this.prismaService.tag.count({
where: {
id: { in: uniqueTagIds },
OR: [{ userId }, { userId: null }]
}
});
if (tagsCount !== uniqueTagIds.length) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

13
apps/api/src/app/activities/activities.controller.ts

@ -315,11 +315,13 @@ export class ActivitiesController {
data: {
...data,
date,
account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
account: accountId
? {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
}
: { disconnect: true },
SymbolProfile: {
connect: {
dataSource_symbol: {
@ -338,6 +340,7 @@ export class ActivitiesController {
}),
user: { connect: { id: this.request.user.id } }
},
userId: this.request.user.id,
where: {
id
}

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

@ -14,6 +14,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -36,6 +37,7 @@ import { ActivitiesService } from './activities.service';
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],

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

@ -12,6 +12,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
@ -61,7 +62,8 @@ export class ActivitiesService {
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {}
public areCashActivitiesExcludedByFilters(filters: Filter[] = []) {
@ -107,6 +109,13 @@ export class ActivitiesService {
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
const activities = await this.prismaService.order.findMany({
where: {
userId,
@ -153,6 +162,15 @@ export class ActivitiesService {
userId: string;
}
): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
tagIds: tags.map(({ id }) => {
return id;
}),
userId: data.userId
});
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) {
@ -167,7 +185,6 @@ export class ActivitiesService {
}
const accountId = data.accountId;
const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId;
@ -932,6 +949,7 @@ export class ActivitiesService {
public async updateActivity({
data,
userId,
where
}: {
data: Prisma.OrderUpdateInput & {
@ -942,14 +960,22 @@ export class ActivitiesService {
tags?: { id: string }[];
type?: ActivityType;
};
userId: string;
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
const tags = data.tags ?? [];
await this.tagService.validateTagIds({
userId,
tagIds: tags.map(({ id }) => {
return id;
})
});
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;
if (
@ -957,10 +983,6 @@ export class ActivitiesService {
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
}
delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else {
@ -989,19 +1011,13 @@ export class ActivitiesService {
delete data.symbol;
delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
where,
data: { tags: { set: [] } }
});
const activity = await this.prismaService.order.update({
where,
data: {
...data,
isDraft,
tags: {
connect: tags
set: tags
}
}
});

2
apps/api/src/app/endpoints/ai/ai.module.ts

@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -44,6 +45,7 @@ import { AiService } from './ai.service';
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
UserModule
],
providers: [

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

@ -23,6 +23,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -46,6 +47,7 @@ import { BenchmarksService } from './benchmarks.service';
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule

2
apps/api/src/app/endpoints/public/public.module.ts

@ -18,6 +18,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -38,6 +39,7 @@ import { PublicController } from './public.controller';
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
UserModule
],

9
apps/api/src/app/logo/get-logo.dto.ts

@ -0,0 +1,9 @@
import { IsUrl } from 'class-validator';
export class GetLogoDto {
@IsUrl({
protocols: ['http', 'https'],
require_protocol: true
})
url: string;
}

3
apps/api/src/app/logo/logo.controller.ts

@ -12,6 +12,7 @@ import {
import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { GetLogoDto } from './get-logo.dto';
import { LogoService } from './logo.service';
@Controller('logo')
@ -41,7 +42,7 @@ export class LogoController {
@Get()
public async getLogoByUrl(
@Query('url') url: string,
@Query() { url }: GetLogoDto,
@Res() response: Response
) {
try {

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

@ -47,7 +47,7 @@ export class LogoService {
private async getBuffer(aUrl: string) {
const blob = await this.fetchService
.fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
`https://t0.gstatic.com/faviconV2?client=SOCIAL&fallback_opts=TYPE,SIZE,URL&size=64&type=FAVICON&url=${encodeURIComponent(aUrl)}`,
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(

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

@ -91,6 +91,7 @@ describe('PortfolioCalculator', () => {
accountBalanceService,
null,
exchangeRateDataService,
null,
null
);
@ -122,6 +123,7 @@ describe('PortfolioCalculator', () => {
exchangeRateDataService,
null,
null,
null,
null
);

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

@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: true,
hasErrors: false,
positions: [],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),

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

@ -236,6 +236,36 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
};
}
// The dividends, the interest and the liabilities are derived from the
// activities only. Accumulate them upfront so that they survive the bail
// out for symbols without a market price below.
for (const order of orders) {
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
@ -263,7 +293,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
// A missing market price can only affect the units which are held. The
// dividends, the interest and the liabilities do not hold any units and
// are therefore not in error.
const hasActivitiesWithUnits = orders.some(({ type }) => {
return ['BUY', 'SELL'].includes(type);
});
return {
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
@ -271,7 +314,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
hasErrors: hasActivitiesWithUnits,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
@ -288,14 +331,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0)
totalInvestmentWithCurrencyEffect: new Big(0)
};
}
@ -415,29 +452,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date

2
apps/api/src/app/portfolio/portfolio.module.ts

@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -50,6 +51,7 @@ import { RulesService } from './rules.service';
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule

2
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -54,6 +54,7 @@ describe('PortfolioService', () => {
null,
null,
exchangeRateDataService,
null,
null
);
@ -67,6 +68,7 @@ describe('PortfolioService', () => {
exchangeRateDataService,
null,
null,
null,
null
);

38
apps/api/src/middlewares/html-template.middleware.ts

@ -97,20 +97,29 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
private indexHtmlMap: { [languageCode: string]: string } = {};
public constructor(private readonly i18nService: I18nService) {
try {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch (error) {
this.logger.error('Failed to initialize index HTML map', error);
if (!environment.production) {
return;
}
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce((map, languageCode) => {
const indexHtmlPath = join(
__dirname,
'..',
'client',
languageCode,
'index.html'
);
try {
map[languageCode] = readFileSync(indexHtmlPath, 'utf8');
} catch {
this.logger.warn(
`Skipping language '${languageCode}': ${indexHtmlPath} not found`
);
}
return map;
}, {});
}
public use(request: Request, response: Response, next: NextFunction) {
@ -118,7 +127,8 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
let languageCode = path.substr(1, 2);
if (
!(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode)
!(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) ||
!this.indexHtmlMap[languageCode]
) {
languageCode = DEFAULT_LANGUAGE_CODE;
}

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

@ -44,7 +44,7 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
export class DataProviderService implements OnModuleInit {
private readonly logger = new Logger(DataProviderService.name);
private dataProviderMapping: { [dataProviderName: string]: string };
private dataProviderMapping: { [dataProviderName: string]: string } = {};
public constructor(
private readonly configurationService: ConfigurationService,

14
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 { uniqBy } from 'lodash';
import { isArray, uniqBy } from 'lodash';
@Injectable()
export class FinancialModelingPrepService
@ -440,10 +440,14 @@ export class FinancialModelingPrepService
signal: AbortSignal.timeout(requestTimeout)
}
)
.then(
(res) =>
res.json() as unknown as { price: number; symbol: string }[]
)
.then(async (res) => {
const json = (await res.json()) as unknown as {
price: number;
symbol: string;
}[];
return isArray(json) ? json : [];
})
]);
for (const { currency, symbolTarget } of assetProfileResolutions) {

38
apps/api/src/services/tag/tag.service.ts

@ -1,7 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class TagService {
@ -124,4 +125,39 @@ export class TagService {
where
});
}
public async validateTagIds({
tagIds,
userId
}: {
tagIds: string[];
userId: string;
}) {
if (!tagIds?.length) {
return;
}
if (!userId) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const uniqueTagIds = Array.from(new Set(tagIds));
const tagsCount = await this.prismaService.tag.count({
where: {
id: { in: uniqueTagIds },
OR: [{ userId }, { userId: null }]
}
});
if (tagsCount !== uniqueTagIds.length) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

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

@ -40,6 +40,7 @@ import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces';
import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { UserService } from './services/user/user.service';
@ -319,7 +320,11 @@ export class GfAppComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result: HoldingDetailDialogResult) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate([], {
queryParams: {
dataSource: null,

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

@ -15,7 +15,6 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -41,7 +40,7 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
@ -53,9 +52,12 @@ import {
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs';
import { filter, forkJoin } from 'rxjs';
import { AccountDetailDialogParams } from './interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -113,11 +115,24 @@ export class GfAccountDetailDialogComponent implements OnInit {
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef);
inject<
MatDialogRef<GfAccountDetailDialogComponent, AccountDetailDialogResult>
>(MatDialogRef);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@ -155,17 +170,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -208,17 +212,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView

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

@ -132,8 +132,6 @@
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"

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

@ -4,3 +4,7 @@ export interface AccountDetailDialogParams {
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean;
}
export interface AccountDetailDialogResult {
isNavigating?: boolean;
}

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

@ -21,8 +21,8 @@
<mat-card-actions class="pb-3 pt-0 px-3">
<a
class="special"
href="mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=Hello,%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?%0D%0DKind regards"
mat-flat-button
[href]="premiumDataProviderMailHref"
>
<ng-container i18n>Get Access</ng-container>
</a>

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

@ -2,7 +2,10 @@ import { GfAdminPlatformComponent } from '@ghostfolio/client/components/admin-pl
import { GfAdminTagComponent } from '@ghostfolio/client/components/admin-tag/admin-tag.component';
import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import {
E_MAIL_LINE_BREAK,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
@ -80,6 +83,13 @@ export class GfAdminSettingsComponent implements OnInit {
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[
'Hello,',
'',
'I am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?',
'',
'Kind regards'
].join(E_MAIL_LINE_BREAK)}`;
public pricingUrl: string;
public user: User;

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

@ -22,7 +22,7 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
{{ translate(element.name) }}
</td>
</ng-container>

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

@ -3,6 +3,7 @@ import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -68,6 +69,7 @@ export class GfAdminTagComponent implements OnInit {
];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[];
protected readonly translate = translate;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
@ -155,7 +157,12 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sortingDataAccessor = (tag, path) => {
return path === 'name'
? translate(tag.name).toLocaleLowerCase()
: (getLowercase(tag, path) as number | string);
};
this.dataService.updateInfo();

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

@ -1,6 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_PAGE_SIZE,
E_MAIL_LINE_BREAK,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
} from '@ghostfolio/common/config';
@ -62,7 +63,7 @@ import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router, RouterModule } from '@angular/router';
import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -79,9 +80,12 @@ import {
} from 'ionicons/icons';
import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators';
import { filter, switchMap } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
import {
HoldingDetailDialogParams,
HoldingDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -169,7 +173,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected quantity: number;
protected quantityPrecision = 2;
protected reportDataGlitchMail: string;
protected reportDataGlitchMailHref: string;
protected readonly round = round;
protected readonly routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
@ -185,9 +189,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected value: number;
protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
protected readonly dialogRef = inject(
MatDialogRef<GfHoldingDetailDialogComponent>
);
protected readonly dialogRef =
inject<
MatDialogRef<GfHoldingDetailDialogComponent, HoldingDetailDialogResult>
>(MatDialogRef);
private tags: Tag[];
@ -199,6 +204,17 @@ export class GfHoldingDetailDialogComponent implements OnInit {
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
addIcons({
arrowDownCircleOutline,
createOutline,
@ -456,7 +472,20 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : ''
}`;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.reportDataGlitchMailHref = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=${[
'Hello',
'',
'I would like to report a data glitch for',
'',
`Symbol: ${this.assetProfile?.symbol}`,
`Data Source: ${this.assetProfile?.dataSource}`,
'',
'Additional notes:',
'',
'Can you please take a look?',
'',
'Kind regards'
].join(E_MAIL_LINE_BREAK)}`;
if (this.assetProfile?.assetClass) {
this.assetClass = translate(this.assetProfile?.assetClass);
@ -575,17 +604,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -647,17 +665,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
private fetchActivities(filters: Filter[] = this.getActivityFilters()) {
this.dataService
.fetchActivities({

7
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -374,8 +374,6 @@
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
/>
@ -456,7 +454,10 @@
dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true
) {
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
<a
color="warn"
mat-stroked-button
[href]="reportDataGlitchMailHref"
><ion-icon class="mr-1" name="flag-outline" /><span i18n
>Report Data Glitch</span
>...</a

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

@ -15,3 +15,7 @@ export interface HoldingDetailDialogParams {
locale: string;
symbol: string;
}
export interface HoldingDetailDialogResult {
isNavigating?: boolean;
}

10
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { E_MAIL_LINE_BREAK } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
@ -48,8 +49,13 @@ export class GfUserAccountMembershipComponent {
protected hasPermissionToCreateApiKey: boolean;
protected hasPermissionToUpdateUserSettings: boolean;
protected price: number;
protected readonly trySubscriptionMail =
'mailto:hi@ghostfol.io?subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
protected readonly trySubscriptionMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Trial&body=${[
'Hello',
'',
'I am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?',
'',
'Kind regards'
].join(E_MAIL_LINE_BREAK)}`;
protected user: User;
private couponId: string | undefined;

5
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -49,7 +49,10 @@
}
<div class="align-items-center d-flex justify-content-center mt-4">
@if (!user?.subscription?.expiresAt) {
<a class="mx-1" mat-stroked-button [href]="trySubscriptionMail"
<a
class="mx-1"
mat-stroked-button
[href]="trySubscriptionMailHref"
><span i18n>Try Premium</span>
<gf-premium-indicator
class="d-inline-block ml-1"

13
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -5,6 +5,7 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { E_MAIL_LINE_BREAK } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
@ -72,7 +73,7 @@ import { catchError } from 'rxjs/operators';
export class GfUserAccountSettingsComponent implements OnInit {
protected readonly appearancePlaceholder = $localize`Auto`;
protected readonly baseCurrency: string;
protected closeUserAccountMail: string;
protected closeUserAccountMailHref: string;
protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
accessToken: ['', Validators.required]
@ -126,7 +127,15 @@ export class GfUserAccountSettingsComponent implements OnInit {
if (state?.user) {
this.user = state.user;
this.closeUserAccountMail = `mailto:hi@ghostfol.io?subject=Delete Account&body=Hello%0D%0DPlease delete my Ghostfolio account.%0D%0DUser ID: ${this.user.id}%0D%0DKind regards`;
this.closeUserAccountMailHref = `mailto:hi@ghostfol.io?subject=Delete Account&body=${[
'Hello',
'',
'Please delete my Ghostfolio account.',
'',
`User ID: ${this.user.id}`,
'',
'Kind regards'
].join(E_MAIL_LINE_BREAK)}`;
this.hasPermissionToDeleteOwnUser = hasPermission(
this.user.permissions,

6
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -349,7 +349,11 @@
first before your Ghostfolio account can be closed.
</div>
} @else if (hasPermissionToRequestOwnUserDeletion) {
<a color="warn" i18n mat-flat-button [href]="closeUserAccountMail"
<a
color="warn"
i18n
mat-flat-button
[href]="closeUserAccountMailHref"
>Close Account</a
>
}

3
apps/client/src/app/pages/about/changelog/changelog-page.component.ts

@ -1,8 +1,9 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarkdownModule, NgxSkeletonLoaderModule],
selector: 'gf-changelog-page',
styleUrls: ['./changelog-page.scss'],

3
apps/client/src/app/pages/about/license/license-page.component.ts

@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarkdownModule],
selector: 'gf-license-page',
styleUrls: ['./license-page.scss'],

3
apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { IonIcon } from '@ionic/angular/standalone';
@ -8,6 +8,7 @@ import { arrowForwardOutline } from 'ionicons/icons';
const ossFriends = require('../../../../assets/oss-friends.json');
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon, MatButtonModule, MatCardModule],
selector: 'gf-oss-friends-page',
styleUrls: ['./oss-friends-page.scss'],

6
apps/client/src/app/pages/about/overview/about-overview-page.component.ts

@ -5,6 +5,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
@ -25,6 +26,7 @@ import {
} from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-about-overview-page',
@ -68,9 +70,9 @@ export class GfAboutOverviewPageComponent implements OnInit {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}
}

3
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts

@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarkdownModule],
selector: 'gf-privacy-policy-page',
styleUrls: ['./privacy-policy-page.scss'],

3
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts

@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarkdownModule],
selector: 'gf-terms-of-service-page',
styleUrls: ['./terms-of-service-page.scss'],

20
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,5 +1,8 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
@ -15,6 +18,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
@ -36,6 +40,7 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfAccountsTableComponent, GfFabComponent, RouterModule],
selector: 'gf-accounts-page',
@ -120,9 +125,9 @@ export class GfAccountsPageComponent implements OnInit {
this.user.permissions,
permissions.updateAccount
);
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
this.fetchAccounts();
@ -241,7 +246,8 @@ export class GfAccountsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
@ -260,7 +266,11 @@ export class GfAccountsPageComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.fetchAccounts();
this.router.navigate(['.'], { relativeTo: this.route });

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

@ -22,7 +22,13 @@ import {
HttpHeaders,
HttpParams
} from '@angular/common/http';
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { format, startOfYear } from 'date-fns';
@ -33,6 +39,7 @@ import { catchError, map, Observable, of, OperatorFunction } from 'rxjs';
import { FetchFailure, FetchResult } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
CommonModule,

8
apps/client/src/app/pages/auth/auth-page.component.ts

@ -4,11 +4,17 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Component, DestroyRef, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-auth-page',
styleUrls: ['./auth-page.scss'],
templateUrl: './auth-page.html'

3
apps/client/src/app/pages/demo/demo-page.component.ts

@ -3,10 +3,11 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Router } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-demo-page',
standalone: true,

6
apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts

@ -4,6 +4,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
@ -14,6 +15,7 @@ import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -38,9 +40,9 @@ export class GfFaqOverviewPageComponent {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}
}

6
apps/client/src/app/pages/faq/saas/saas-page.component.ts

@ -4,6 +4,7 @@ import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
@ -14,6 +15,7 @@ import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -42,9 +44,9 @@ export class GfSaasPageComponent {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}
}

7
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts

@ -1,11 +1,16 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA
} from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],

12
apps/client/src/app/pages/features/features-page.component.ts

@ -5,13 +5,19 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
GfPremiumIndicatorComponent,
@ -46,9 +52,9 @@ export class GfFeaturesPageComponent {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
this.hasPermissionForSubscription = hasPermission(

3
apps/client/src/app/pages/i18n/i18n-page.component.ts

@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-i18n-page',
standalone: true,

3
apps/client/src/app/pages/landing/landing-page.component.ts

@ -8,7 +8,7 @@ import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
@ -22,6 +22,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
GfCarouselComponent,

3
apps/client/src/app/pages/markets/markets-page.component.ts

@ -1,8 +1,9 @@
import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component';
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfHomeMarketComponent],
selector: 'gf-markets-page',

6
apps/client/src/app/pages/open/open-page.component.ts

@ -4,6 +4,7 @@ import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
@ -14,6 +15,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfValueComponent, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -42,9 +44,9 @@ export class GfOpenPageComponent implements OnInit {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}
}

141
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { downloadAsFile } from '@ghostfolio/common/helper';
import {
Activity,
@ -10,6 +9,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -29,17 +29,12 @@ import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces';
import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
import { ActivitiesPageParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -59,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean;
protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected sortColumn = 'date';
@ -77,37 +73,9 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params: ActivitiesPageParams) => {
if (params.activityId && (params.createDialog || params.editDialog)) {
return this.dataService
.fetchActivity(params.activityId)
.pipe(map((activity) => ({ activity, params })));
}
return of({ params, activity: undefined });
})
)
.subscribe(({ activity, params }) => {
if (params.createDialog) {
this.openCreateActivityDialog(activity);
} else if (params.editDialog) {
if (activity) {
this.openUpdateActivityDialog(activity);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@ -149,10 +117,6 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
protected onCloneActivity(aActivity: Activity) {
this.openCreateActivityDialog(aActivity);
}
protected onDeleteActivities() {
this.dataService
.deleteActivities({
@ -308,12 +272,6 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
}
private fetchActivities() {
// Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
@ -343,48 +301,16 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
);
}
this.changeDetectorRef.markForCheck();
});
}
private openUpdateActivityDialog(aActivity: Activity) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity: aActivity,
accounts: this.user?.accounts,
user: this.user
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity: UpdateOrderDto) => {
if (activity) {
this.dataService
.putActivity(activity)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private isCalendarYear(dateRange?: DateRange) {
if (!dateRange) {
return false;
@ -393,59 +319,6 @@ export class GfActivitiesPageComponent implements OnInit {
return /^\d{4}$/.test(dateRange);
}
private openCreateActivityDialog(aActivity?: Activity) {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
accounts: this.user?.accounts,
activity: {
...aActivity,
accountId: aActivity?.accountId,
assetProfile: null,
date: new Date(),
id: null,
fee: 0,
type: aActivity?.type ?? 'BUY',
unitPrice: null
},
user: this.user
} satisfies CreateOrUpdateActivityDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((transaction: CreateOrderDto | null) => {
if (transaction) {
this.dataService.postActivity(transaction).subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private updateUser(aUser: User) {
this.user = aUser;

11
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -25,8 +25,6 @@
(activitiesDeleted)="onDeleteActivities()"
(activityClicked)="onClickActivity($event)"
(activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
@ -43,6 +41,13 @@
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<gf-fab [queryParams]="{ createDialog: true }" />
<gf-fab
[routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
"
/>
}
</div>
<router-outlet />

29
apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts

@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfActivitiesPageComponent } from './activities-page.component';
import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component';
const { clone, create, update } =
internalRoutes.portfolio.subRoutes.activities.subRoutes;
export const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'create' },
path: create.path,
title: create.title
},
{
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'clone' },
path: clone.path,
title: clone.title
},
{
component: GfActivityDialogHostComponent,
data: { mode: 'update' },
path: update.path,
title: update.title
}
],
path: ':activityId'
}
],
component: GfActivitiesPageComponent,
path: '',
title: internalRoutes.portfolio.subRoutes.activities.title

169
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -0,0 +1,169 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces';
import { ActivityDialogMode } from './types/activity-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activity-dialog-host',
template: ''
})
export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as ActivityDialogMode;
const activityId = this.route.snapshot.paramMap.get('activityId');
const activity$: Observable<Activity | undefined> = activityId
? this.dataService.fetchActivity(activityId)
: of(undefined);
this.userService
.get()
.pipe(
switchMap((user) => {
return activity$.pipe(
map((activity) => {
return { activity, user };
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
if (mode === 'update') {
if (!activity) {
this.navigateBack();
return;
}
this.openDialog({ activity, user, isUpdate: true });
return;
}
if (mode === 'clone' && !activity) {
this.navigateBack();
return;
}
this.openDialog({
user,
activity: {
...activity,
accountId: activity?.accountId,
assetProfile: activity?.assetProfile ?? null,
date: new Date(),
fee: 0,
id: null,
type: activity?.type ?? 'BUY',
unitPrice: null
},
isUpdate: false
});
}
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
}
private navigateBack() {
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink
);
}
private openDialog({
activity,
isUpdate,
user
}: {
activity: CreateOrUpdateActivityDialogParams['activity'];
isUpdate: boolean;
user: User;
}) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity,
user,
accounts: user?.accounts
},
height: deviceType === 'mobile' ? '98vh' : '80vh',
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putActivity(result as UpdateOrderDto)
: this.dataService.postActivity(result as CreateOrderDto);
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
error: () => {
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating back
// destroys this component and the refreshed user is what makes the
// activities page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

1
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts

@ -0,0 +1 @@
export type ActivityDialogMode = 'clone' | 'create' | 'update';

7
apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts

@ -1,7 +0,0 @@
import { Params } from '@angular/router';
export interface ActivitiesPageParams extends Params {
activityId?: string;
createDialog?: string;
editDialog?: string;
}

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

@ -1,5 +1,8 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
@ -604,7 +607,8 @@ export class GfAllocationsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
@ -623,7 +627,11 @@ export class GfAllocationsPageComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate(['.'], { relativeTo: this.route });
});
}

6
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -8,6 +8,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
@ -32,6 +33,7 @@ import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
GfPremiumIndicatorComponent,
@ -123,9 +125,9 @@ export class GfPricingPageComponent implements OnInit {
this.label = this.user?.subscription?.offer?.label;
this.price = this.user?.subscription?.offer?.price;
this.priceId = this.user?.subscription?.offer?.priceId;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}

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

@ -17,6 +17,7 @@ import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
@ -38,6 +39,7 @@ import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [
GfActivitiesTableComponent,

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

@ -6,6 +6,7 @@ import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
@ -21,6 +22,7 @@ import { UserAccountRegistrationDialogParams } from './user-account-registration
import { GfUserAccountRegistrationDialogComponent } from './user-account-registration-dialog/user-account-registration-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfLogoComponent, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],

3
apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts

@ -3,10 +3,11 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterModule],
selector: 'gf-resources-glossary',
styleUrls: ['./resources-glossary.component.scss'],

3
apps/client/src/app/pages/resources/guides/resources-guides.component.ts

@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterModule],
selector: 'gf-resources-guides',
styleUrls: ['./resources-guides.component.scss'],

3
apps/client/src/app/pages/resources/markets/resources-markets.component.ts

@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-resources-markets',
styleUrls: ['./resources-markets.component.scss'],
templateUrl: './resources-markets.component.html'

3
apps/client/src/app/pages/resources/overview/resources-overview.component.ts

@ -1,9 +1,10 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterModule],
selector: 'gf-resources-overview',
styleUrls: ['./resources-overview.component.scss'],

3
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts

@ -1,7 +1,7 @@
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
@ -9,6 +9,7 @@ import { addIcons } from 'ionicons';
import { chevronForwardOutline } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [IonIcon, MatCardModule, RouterModule],
selector: 'gf-personal-finance-tools-page',

4
apps/client/src/app/pages/webauthn/webauthn-page.component.ts

@ -2,6 +2,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
@ -13,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, MatProgressSpinnerModule],
selector: 'gf-webauthn-page',
@ -56,7 +58,9 @@ export class GfWebauthnPageComponent implements OnInit {
},
(error) => {
console.error(error);
this.hasError = true;
this.changeDetectorRef.markForCheck();
}
);

2
libs/common/src/lib/config.ts

@ -184,6 +184,8 @@ export const DERIVED_CURRENCIES = [
}
];
export const E_MAIL_LINE_BREAK = '%0D%0A';
export const GATHER_ASSET_PROFILE_PROCESS_JOB_NAME = 'GATHER_ASSET_PROFILE';
export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = {
attempts: 12,

3
libs/common/src/lib/dtos/create-order.dto.ts

@ -4,6 +4,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
@ -62,8 +63,10 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
@IsEnum(Type)

3
libs/common/src/lib/dtos/update-order.dto.ts

@ -4,6 +4,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
@ -63,8 +64,10 @@ export class UpdateOrderDto {
@IsString()
symbol: string;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
@IsString()

2
libs/common/src/lib/routes/interfaces/internal-route.interface.ts

@ -3,7 +3,7 @@ import { User } from '@ghostfolio/common/interfaces';
export interface InternalRoute {
excludeFromAssistant?: boolean | ((aUser: User) => boolean);
path?: string;
routerLink: string[];
routerLink: string[] | ((...aParams: string[]) => string[]);
subRoutes?: Record<string, InternalRoute>;
title?: string;
}

21
libs/common/src/lib/routes/routes.ts

@ -125,6 +125,27 @@ export const internalRoutes = {
activities: {
path: 'activities',
routerLink: ['/portfolio', 'activities'],
subRoutes: {
clone: {
path: 'clone',
routerLink: (aActivityId: string) => {
return ['/portfolio', 'activities', aActivityId, 'clone'];
},
title: $localize`Clone Activity`
},
create: {
path: 'create',
routerLink: ['/portfolio', 'activities', 'create'],
title: $localize`Add Activity`
},
update: {
path: 'update',
routerLink: (aActivityId: string) => {
return ['/portfolio', 'activities', aActivityId, 'update'];
},
title: $localize`Update Activity`
}
},
title: $localize`Activities`
},
allocations: {

18
libs/ui/src/lib/activities-table/activities-table.component.html

@ -160,8 +160,8 @@
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="align-items-center d-flex line-height-1 text-nowrap">
<td *matCellDef="let element" class="line-height-normal px-1" mat-cell>
<div class="align-items-center d-flex text-nowrap">
<span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
@ -476,18 +476,24 @@
</span>
</button>
}
<button mat-menu-item (click)="onUpdateActivity(element)">
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.update"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
</span>
</button>
<button mat-menu-item (click)="onCloneActivity(element)">
</a>
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.clone"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span><ng-container i18n>Clone</ng-container>...</span>
</span>
</button>
</a>
<button
mat-menu-item
[disabled]="!element.isDraft"

33
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -9,9 +9,9 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { provideRouter, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -366,6 +366,9 @@ export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
applicationConfig({
providers: [provideRouter([])]
}),
moduleMetadata({
imports: [
CommonModule,
@ -418,8 +421,8 @@ export const Loading: Story = {
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
baseCurrency: 'USD',
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
@ -469,3 +472,27 @@ export const Pagination: Story = {
totalItems: 50
}
};
export const Actions: Story = {
args: {
dataSource,
baseCurrency: 'USD',
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: true,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: true,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: true,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};

35
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -9,7 +9,7 @@ import {
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
@ -50,6 +50,7 @@ import {
} from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Type as ActivityType } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -95,7 +96,8 @@ import { GfValueComponent } from '../value/value.component';
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
ReactiveFormsModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-activities-table',
@ -123,8 +125,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
@Output() activitiesDeleted = new EventEmitter<void>();
@Output() activityClicked = new EventEmitter<AssetProfileIdentifier>();
@Output() activityDeleted = new EventEmitter<string>();
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<void>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>();
@ -151,6 +151,25 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public readonly showCheckbox = input(false);
public readonly showNameColumn = input(true);
protected readonly activityDialogRouterLinks = computed(() => {
const { clone, update } =
internalRoutes.portfolio.subRoutes.activities.subRoutes;
const routerLinks = new Map<
string,
{ clone: string[]; update: string[] }
>();
for (const { id } of this.dataSource()?.data ?? []) {
routerLinks.set(id, {
clone: clone.routerLink(id),
update: update.routerLink(id)
});
}
return routerLinks;
});
protected readonly displayedColumns = computed(() => {
let columns = [
'select',
@ -291,10 +310,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
}
}
public onCloneActivity(aActivity: OrderWithAccount) {
this.activityToClone.emit(aActivity);
}
public onDeleteActivities() {
this.notificationService.confirm({
confirmFn: () => {
@ -349,10 +364,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
});
}
public onUpdateActivity(aActivity: OrderWithAccount) {
this.activityToUpdate.emit(aActivity);
}
public sortByValue(
a: { key: ActivityType; value: string },
b: { key: ActivityType; value: string }

28
libs/ui/src/lib/assistant/assistant.component.ts

@ -688,21 +688,31 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
private searchQuickLinks(aSearchTerm: string): SearchResultItem[] {
const searchTerm = aSearchTerm.toLowerCase();
const allRoutes = Object.values<InternalRoute>(internalRoutes)
.filter(({ excludeFromAssistant }) => {
if (isFunction(excludeFromAssistant)) {
return excludeFromAssistant(this.user);
}
const isIncludedInAssistant = ({ excludeFromAssistant }: InternalRoute) => {
if (isFunction(excludeFromAssistant)) {
return excludeFromAssistant(this.user);
}
return !excludeFromAssistant;
})
return !excludeFromAssistant;
};
const allRoutes = Object.values<InternalRoute>(internalRoutes)
.filter(isIncludedInAssistant)
.reduce<InternalRoute[]>((acc, route) => {
acc.push(route);
if (route.subRoutes) {
acc.push(...Object.values(route.subRoutes));
acc.push(
...Object.values(route.subRoutes).filter(isIncludedInAssistant)
);
}
return acc;
}, []);
}, [])
.filter((route): route is InternalRoute & { routerLink: string[] } => {
// Routes with a parameterized router link cannot be linked to without
// their parameters and are therefore not offered as quick links
return Array.isArray(route.routerLink);
});
const fuse = new Fuse(allRoutes, {
keys: ['title'],

6
libs/ui/src/lib/benchmark/benchmark.component.html

@ -22,7 +22,11 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<td
*matCellDef="let element"
class="line-height-normal px-1 text-nowrap"
mat-cell
>
<div class="text-truncate">
{{ element?.name }}
</div>

2
libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts

@ -15,6 +15,6 @@ export class EntityLogoImageSourceService {
}
public getLogoUrlByUrl(url: string) {
return `../api/v1/logo?url=${url}`;
return `../api/v1/logo?url=${encodeURIComponent(url)}`;
}
}

2
libs/ui/src/lib/fab/fab.component.html

@ -3,7 +3,7 @@
color="primary"
mat-fab
[queryParams]="queryParams()"
[routerLink]="[]"
[routerLink]="routerLink()"
>
<ion-icon name="add-outline" size="large" />
</a>

3
libs/ui/src/lib/fab/fab.component.ts

@ -13,7 +13,8 @@ import { addOutline } from 'ionicons/icons';
templateUrl: './fab.component.html'
})
export class GfFabComponent {
public readonly queryParams = input.required<Params>();
public readonly queryParams = input<Params>();
public readonly routerLink = input<string[]>([]);
public constructor() {
addIcons({ addOutline });

2
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -27,7 +27,7 @@
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<td *matCellDef="let element" class="line-height-normal px-1" mat-cell>
<div class="text-truncate">
{{ element.assetProfile.name }}
@if (element.assetProfile.name === element.assetProfile.symbol) {

4
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.26.0",
"version": "3.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.26.0",
"version": "3.27.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

2
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.26.0",
"version": "3.27.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save