Browse Source

Merge branch 'main' into task/improve-check-for-duplicates-in-preview-step-of-activities-import

pull/7541/head
Thomas Kaul 3 weeks ago
parent
commit
6aa866746b
  1. 24
      CHANGELOG.md
  2. 92
      apps/api/src/app/account/account.controller.ts
  3. 80
      apps/api/src/app/account/account.service.ts
  4. 4
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  5. 6
      apps/api/src/app/auth/auth.module.ts
  6. 69
      apps/api/src/app/auth/web-auth.service.ts
  7. 4
      apps/api/src/app/export/export.service.ts
  8. 12
      apps/api/src/app/import/import.service.ts
  9. 30
      apps/api/src/app/portfolio/portfolio.controller.ts
  10. 7
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  11. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  13. 10
      apps/client/src/app/app.component.ts
  14. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  15. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  16. 2
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  17. 13
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  18. 6
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  19. 5
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  20. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  21. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  22. 1
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  23. 9
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  24. 24
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  25. 21
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  26. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  27. 8
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  28. 22
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  29. 54
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  30. 16
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  31. 23
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  32. 34
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  33. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  34. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  35. 2
      libs/common/src/lib/config.ts
  36. 7
      libs/common/src/lib/dtos/create-account.dto.ts
  37. 7
      libs/common/src/lib/dtos/update-account.dto.ts
  38. 25
      libs/common/src/lib/helper.spec.ts
  39. 11
      libs/common/src/lib/helper.ts
  40. 1
      libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts
  41. 12
      libs/common/src/lib/permissions.ts
  42. 5
      libs/common/src/lib/types/account-with-balance.type.ts
  43. 6
      libs/common/src/lib/types/account-with-value.type.ts
  44. 2
      libs/common/src/lib/types/index.ts
  45. 26
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  46. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  47. 14
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  48. 13
      libs/ui/src/lib/activities-table/activities-table.component.html
  49. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  50. 10
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  51. 3
      libs/ui/src/lib/currency-selector/currency-selector.component.scss
  52. 99
      libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts
  53. 30
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  54. 7
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  55. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.scss
  56. 16
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  57. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  58. 7
      libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts
  59. 13
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  60. 4
      package-lock.json
  61. 2
      package.json
  62. 2
      prisma/migrations/20260805120000_removed_balance_from_account/migration.sql
  63. 1
      prisma/schema.prisma
  64. 1
      test/import/not-ok/invalid-platform.json
  65. 1
      test/import/ok/500-activities.json
  66. 1
      test/import/ok/derived-currency.json
  67. 1
      test/import/ok/sample.json

24
CHANGELOG.md

@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Migrated the abstract _Material_ form field from a component to a directive
- Improved the check for duplicates in the preview step of the activities import (regardless of the account)
- Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account)
- Extended the activities import to reuse an existing account of the user by name and currency
@ -18,6 +17,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed the check for duplicates in the preview step of the activities import for activities without a comment
## 3.44.0 - 2026-08-07
### Added
- Added a live preview of the date and number format to the user settings
- Added the country flag to the currency selector
- Added a _Storybook_ story for the currency selector component
- Added the platform logo to the account selectors in the transfer cash balance dialog
- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo
- Warmed up the portfolio snapshot calculation in the background during the biometric authentication
### Changed
- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field
- Migrated the abstract _Material_ form field from a component to a directive
- Removed the redundant `balance` attribute of the account in favor of the account balances
### Fixed
- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages
- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user
- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user
## 3.43.0 - 2026-08-06
### Added

92
apps/api/src/app/account/account.controller.ts

@ -156,32 +156,34 @@ export class AccountController {
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
const { tags: tagIds, ...accountData } = data;
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id
});
} else {
delete accountData.platformId;
return this.accountService.createAccount(
{
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id
});
}
}
@ -257,52 +259,50 @@ export class AccountController {
);
}
const { tags: tagIds, ...accountData } = data;
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id,
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
} else {
// platformId is null, remove it
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id,
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
}
}
}

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

@ -10,6 +10,7 @@ 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 { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -24,7 +25,7 @@ import {
} from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash';
import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -40,7 +41,7 @@ export class AccountService {
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
}: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({
include: {
balances: {
@ -87,7 +88,7 @@ export class AccountService {
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
(AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[];
platform?: Platform;
@ -160,12 +161,18 @@ export class AccountService {
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string,
tagIds?: string[]
): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
public async createAccount({
balance,
data,
tagIds,
userId
}: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({
data: {
@ -182,12 +189,14 @@ export class AccountService {
}
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
@ -216,7 +225,7 @@ export class AccountService {
return account;
}
public async getAccounts(aUserId: string): Promise<Account[]> {
public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({
include: {
activities: true,
@ -295,17 +304,20 @@ export class AccountService {
};
}
public async updateAccount(
params: {
data: Prisma.AccountUpdateInput;
where: Prisma.AccountWhereUniqueInput;
},
aUserId: string,
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
public async updateAccount({
balance,
data,
tagIds,
userId,
where
}: {
balance?: number;
data: Prisma.AccountUpdateInput;
tagIds?: string[];
userId: string;
where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.update({
data: {
@ -324,12 +336,14 @@ export class AccountService {
where
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),

4
apps/api/src/app/account/interfaces/cash-details.interface.ts

@ -1,6 +1,6 @@
import { Account } from '@prisma/client';
import { AccountWithBalance } from '@ghostfolio/common/types';
export interface CashDetails {
accounts: Account[];
accounts: AccountWithBalance[];
balanceInBaseCurrency: number;
}

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

@ -1,14 +1,17 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
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 { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -24,14 +27,17 @@ import { OidcStrategy } from './oidc.strategy';
@Module({
controllers: [AuthController],
imports: [
ApiModule,
ConfigurationModule,
FetchModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
}),
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SubscriptionModule,
UserModule
],

69
apps/api/src/app/auth/web-auth.service.ts

@ -1,6 +1,15 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { AuthDeviceDto } from '@ghostfolio/common/dtos';
import {
AssertionCredentialJSON,
@ -29,6 +38,7 @@ import {
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { isPast } from 'date-fns';
import ms from 'ms';
@Injectable()
@ -36,9 +46,12 @@ export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -155,6 +168,9 @@ export class WebAuthService {
throw new Error('Device not found');
}
// Compute in the background during the biometric authentication
void this.warmUpPortfolioSnapshot({ userId: device.userId });
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [],
rpID: this.rpID,
@ -233,4 +249,57 @@ export class WebAuthService {
throw new Error();
}
private async isPortfolioSnapshotExpired(portfolioSnapshotKey: string) {
try {
const { expiration }: PortfolioSnapshotValue = JSON.parse(
await this.redisCacheService.get(portfolioSnapshotKey)
);
return isPast(new Date(expiration));
} catch {
return true;
}
}
private async warmUpPortfolioSnapshot({ userId }: { userId: string }) {
try {
const user = await this.userService.user({ id: userId });
if (!user) {
return;
}
const userSettings = user.settings.settings;
const filters = this.apiService.buildFiltersFromUserSettings({
userSettings
});
const portfolioSnapshotKey =
this.redisCacheService.getPortfolioSnapshotKey({ filters, userId });
if (await this.isPortfolioSnapshotExpired(portfolioSnapshotKey)) {
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters,
userId,
calculationType: userSettings.performanceCalculationType,
userCurrency: userSettings.baseCurrency
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId: portfolioSnapshotKey,
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
}
} catch (error) {
this.logger.error(
`Portfolio snapshot of user '${userId}' could not be warmed up`,
error
);
}
}
}

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

@ -102,7 +102,6 @@ export class ExportService {
})
.map(
({
balance,
balances,
comment,
currency,
@ -111,13 +110,12 @@ export class ExportService {
platform,
platformId,
tags
}) => {
}): ExportResponse['accounts'][number] => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),

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

@ -411,6 +411,7 @@ export class ImportService {
);
const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
@ -464,11 +465,12 @@ export class ImportService {
};
}
const newAccount = await this.accountService.createAccount(
accountObject,
user.id,
tagIds
);
const newAccount = await this.accountService.createAccount({
tagIds,
balance: accountWithBalances.balance,
data: accountObject,
userId: user.id
});
// Store the new to old account ID mappings for updating activities
if (accountWithSameIdOfOtherUser && oldAccountId) {

30
apps/api/src/app/portfolio/portfolio.controller.ts

@ -136,7 +136,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -180,7 +180,7 @@ export class PortfolioController {
hasDetails === false ||
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -374,7 +374,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -491,19 +491,19 @@ export class PortfolioController {
filterByTags: tags
});
let { investments, streaks } = await this.portfolioService.getInvestments({
filters,
groupBy,
impersonationId,
dateRange: range,
savingsRate: this.request.user?.settings?.settings.savingsRate,
userId: this.request.user.id
});
let { investments, savingsRate, streaks } =
await this.portfolioService.getInvestments({
filters,
groupBy,
impersonationId,
dateRange: range,
userId: this.request.user.id
});
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -521,6 +521,8 @@ export class PortfolioController {
'currentStreak',
'longestStreak'
]);
savingsRate = null;
}
if (
@ -537,7 +539,7 @@ export class PortfolioController {
]);
}
return { investments, streaks };
return { investments, savingsRate, streaks };
}
@Get('performance')
@ -578,7 +580,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN'

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

@ -16,8 +16,9 @@ import {
AssetProfileIdentifier,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Account, DataSource } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
@ -219,7 +220,7 @@ describe('PortfolioService', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const cashAccount: Account = {
const cashAccount: AccountWithBalance = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
@ -444,7 +445,7 @@ describe('PortfolioService', () => {
beforeEach(() => {
jest
.spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]);
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest
.spyOn(exchangeRateDataService, 'toCurrency')

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

@ -64,6 +64,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import {
AccountWithBalance,
AccountWithValue,
DateRange,
GroupBy,
@ -75,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Inject, Injectable, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
Account,
Type as ActivityType,
AssetClass,
AssetSubClass,
@ -413,19 +413,18 @@ export class PortfolioService {
filters,
groupBy,
impersonationId,
savingsRate,
userId
}: {
dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
savingsRate: number;
userId: string;
}): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -438,6 +437,7 @@ export class PortfolioService {
if (activities.length === 0) {
return {
savingsRate,
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
@ -484,6 +484,7 @@ export class PortfolioService {
return {
investments,
savingsRate,
streaks
};
}
@ -2142,7 +2143,7 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & {
let currentAccounts: (AccountWithBalance & {
Order?: Order[];
platform?: Platform;
tags?: Tag[];

2
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
if (
hasReadRestrictedAccessPermission({
impersonationId,
user
accesses: user?.accessesGet
}) ||
isRestrictedView(user)
) {

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

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string;
public currentSubRoute: string;
public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem;
public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
});
this.router.events
@ -291,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions,
permissions.accessAdminControl
),
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.createActivity
@ -308,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch
),
hasPermissionToUpdateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.updateActivity
) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',

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

@ -14,7 +14,11 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -225,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}

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

@ -158,8 +158,8 @@
[pageSize]="pageSize"
[showAccountColumn]="false"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
@ -183,8 +183,8 @@
[currentBalance]="balance"
[locale]="user?.settings?.locale"
[showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView
"
(accountBalanceCreated)="onAddAccountBalance($event)"

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

@ -1,8 +1,8 @@
export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean;
impersonationId: string | null;
}
export interface AccountDetailDialogResult {

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

@ -22,13 +22,12 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) {
<gf-entity-logo
class="d-inline mr-1"
[tooltip]="element.name"
[url]="element.url"
/>
}
<gf-entity-logo
class="d-inline mr-1"
[hasPlaceholder]="true"
[tooltip]="element.name"
[url]="element.url"
/>
<span>{{ element.name }}</span>
</td></ng-container
>

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

@ -59,7 +59,11 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" />
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[url]="element.url"
/>
<div>
@if (isGhostfolioDataProvider(element)) {
<a

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

@ -22,6 +22,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
@ -65,7 +66,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client';
import { MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
@ -117,7 +118,7 @@ import {
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnInit {
protected accounts: Account[];
protected accounts: AccountWithValue[];
protected activitiesCount: number;
protected assetClass: string;
protected assetProfile: Pick<

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

@ -382,7 +382,7 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView
!data.impersonationId && !user?.settings?.isRestrictedView
"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
@ -390,8 +390,8 @@
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"

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

@ -7,11 +7,11 @@ export interface HoldingDetailDialogParams {
colorScheme: ColorScheme;
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToAccessAdminControl: boolean;
hasPermissionToCreateActivity: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateActivity: boolean;
impersonationId: string | null;
locale: string;
symbol: string;
}

1
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -146,7 +146,6 @@ export class GfHomeWatchlistComponent implements OnInit {
GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams
>(GfCreateWatchlistItemDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE

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

@ -22,6 +22,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
inject,
@ -50,6 +51,7 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@ -108,10 +110,17 @@ export class GfUserAccountSettingsComponent implements OnInit {
'uk',
'zh'
];
protected readonly previewDate = new Date().toISOString();
protected readonly previewValue = 9999.99;
protected user: User;
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly settingsStorageService = inject(SettingsStorageService);

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

@ -146,7 +146,7 @@
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="align-items-center d-flex mb-4">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted">
@ -154,7 +154,10 @@
</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-form-field
appearance="outline"
class="w-100 with-value-hint"
>
<mat-select
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
@ -168,6 +171,23 @@
<mat-option [value]="locale">{{ locale }}</mat-option>
}
</mat-select>
<mat-hint class="d-flex mt-2 text-muted">
<gf-value
[deviceType]="deviceType()"
[isDate]="true"
[isLoading]="isLoading"
[locale]="user.settings.locale"
[value]="previewDate"
/>
<span class="mx-1">·</span>
<gf-value
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user.settings.locale"
[unit]="user.settings.baseCurrency"
[value]="previewValue"
/>
</mat-hint>
</mat-form-field>
</div>
</div>

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

@ -12,6 +12,7 @@ import {
} from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications';
@ -29,7 +30,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@ -48,11 +49,11 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
templateUrl: './accounts-page.html'
})
export class GfAccountsPageComponent implements OnInit {
protected accounts: AccountModel[];
protected accounts: AccountWithValue[];
protected activitiesCount = 0;
protected hasImpersonationId: boolean;
protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null;
protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0;
protected user: User;
@ -103,12 +104,16 @@ export class GfAccountsPageComponent implements OnInit {
});
}
protected get hasImpersonationId() {
return !!this.impersonationId;
}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
});
this.userService.stateChanged
@ -155,7 +160,7 @@ export class GfAccountsPageComponent implements OnInit {
});
}
protected onUpdateAccount(aAccount: AccountModel) {
protected onUpdateAccount(aAccount: AccountWithValue) {
this.router.navigate([], {
queryParams: { accountId: aAccount.id, editDialog: true }
});
@ -194,7 +199,7 @@ export class GfAccountsPageComponent implements OnInit {
name,
platformId,
tags
}: AccountModel & { tags?: Tag[] }) {
}: AccountWithValue & { tags?: Tag[] }) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams
@ -251,11 +256,11 @@ export class GfAccountsPageComponent implements OnInit {
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -46,10 +46,11 @@
<div [class.d-none]="platforms?.length < 1">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
@if (selectedPlatform?.url) {
@if (selectedPlatform) {
<gf-entity-logo
class="ml-3"
matPrefix
[hasPlaceholder]="true"
[tooltip]="selectedPlatform?.name"
[url]="selectedPlatform?.url"
/>
@ -71,6 +72,7 @@
<span class="d-flex">
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="platformEntry.name"
[url]="platformEntry.url"
/>

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

@ -1,9 +1,13 @@
import { User } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Account, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
export interface CreateOrUpdateAccountDialogParams {
account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & {
account: Omit<
AccountWithBalance,
'createdAt' | 'id' | 'updatedAt' | 'userId'
> & {
id: string | null;
tags?: Tag[];
};

22
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -61,12 +61,22 @@ export class GfTransferBalanceDialogComponent {
private readonly dialogRef =
inject<MatDialogRef<GfTransferBalanceDialogComponent>>(MatDialogRef);
protected get selectedFromAccount() {
return this.getAccountById(
this.transferBalanceForm.controls.fromAccount.value
);
}
protected get selectedToAccount() {
return this.getAccountById(
this.transferBalanceForm.controls.toAccount.value
);
}
public ngOnInit() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe(
(id) => {
const currency = this.accounts.find((account) => {
return account.id === id;
})?.currency;
const currency = this.getAccountById(id)?.currency;
if (currency) {
this.currency = currency;
@ -101,4 +111,10 @@ export class GfTransferBalanceDialogComponent {
return null;
}
private getAccountById(aId: string | null) {
return this.accounts.find(({ id }) => {
return id === aId;
});
}
}

54
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -10,16 +10,29 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>From</mat-label>
<mat-select formControlName="fromAccount">
<mat-select-trigger>
<div class="d-flex">
@if (selectedFromAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedFromAccount?.platform?.name"
[url]="selectedFromAccount?.platform?.url"
/>
}
<span>{{ selectedFromAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
@ -31,16 +44,29 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>To</mat-label>
<mat-select formControlName="toAccount">
<mat-select-trigger>
<div class="d-flex">
@if (selectedToAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedToAccount?.platform?.name"
[url]="selectedToAccount?.platform?.url"
/>
}
<span>{{ selectedToAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>

16
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -87,9 +87,10 @@
<mat-select formControlName="accountId">
<mat-select-trigger>
<div class="d-flex">
@if (selectedAccount?.platform?.url) {
@if (selectedAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedAccount?.platform?.name"
[url]="selectedAccount?.platform?.url"
/>
@ -103,13 +104,12 @@
@for (account of data.accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>

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

@ -17,7 +17,11 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -85,7 +89,6 @@ export class GfAllocationsPageComponent implements OnInit {
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected hasImpersonationId: boolean;
protected holdings: {
[symbol: string]: Pick<
PortfolioPosition['assetProfile'],
@ -97,6 +100,7 @@ export class GfAllocationsPageComponent implements OnInit {
| 'name'
> & { etfProvider: string; value: number };
};
protected impersonationId: string | null;
protected isLoading = false;
protected markets: PortfolioDetails['markets'];
protected marketsAdvanced: {
@ -169,7 +173,7 @@ export class GfAllocationsPageComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck();
});
@ -224,7 +228,12 @@ export class GfAllocationsPageComponent implements OnInit {
}
protected showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}
private extractCurrency({
@ -618,11 +627,11 @@ export class GfAllocationsPageComponent implements OnInit {
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

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

@ -16,7 +16,11 @@ import {
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -79,8 +83,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected bottom3: PortfolioPosition[];
protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasImpersonationId: boolean;
protected hasPermissionToReadAiPrompt: boolean;
protected impersonationId: string | null;
protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Invested Capital`;
protected investmentsByGroup: InvestmentItem[];
@ -100,6 +104,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected performanceDataItemsInPercentage: HistoricalDataItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
protected precision = 2;
protected savingsRatePerMonth: number | undefined;
protected streaks: PortfolioInvestmentsResponse['streaks'];
protected top3: PortfolioPosition[];
protected unitCurrentStreak: string;
@ -131,18 +136,13 @@ export class GfAnalysisPageComponent implements OnInit {
}
get savingsRate() {
const savingsRatePerMonth =
this.hasImpersonationId || this.user.settings.isRestrictedView
? undefined
: this.user?.settings?.savingsRate;
if (savingsRatePerMonth === undefined) {
if (!this.savingsRatePerMonth) {
return undefined;
}
return this.mode() === 'year'
? savingsRatePerMonth * 12
: savingsRatePerMonth;
? this.savingsRatePerMonth * 12
: this.savingsRatePerMonth;
}
public ngOnInit() {
@ -150,7 +150,7 @@ export class GfAnalysisPageComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck();
});
@ -241,6 +241,15 @@ export class GfAnalysisPageComponent implements OnInit {
});
}
protected showValuesInPercentage() {
return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}
private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
@ -267,8 +276,9 @@ export class GfAnalysisPageComponent implements OnInit {
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => {
.subscribe(({ investments, savingsRate, streaks }) => {
this.investmentsByGroup = investments;
this.savingsRatePerMonth = savingsRate;
this.streaks = streaks;
this.unitCurrentStreak =
this.mode() === 'year'

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

@ -398,9 +398,7 @@
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="performanceDataItems"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
/>
@ -456,9 +454,7 @@
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale"
[savingsRate]="savingsRate"
@ -493,9 +489,7 @@
[benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale"
/>

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

@ -21,7 +21,7 @@
[locale]="user?.settings?.locale"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate"
[savingsRate]="hasImpersonationId ? 0 : user?.settings?.savingsRate"
[style.opacity]="
user?.subscription?.type === 'Basic' ? '0.67' : 'initial'
"

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

@ -115,7 +115,6 @@ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].interestInBaseCurrency',
'accounts[*].value',
'accounts[*].valueInBaseCurrency',
'activities[*].account.balance',
'activities[*].account.comment',
'activities[*].assetProfile.symbolMapping',
'activities[*].assetProfile.watchedByCount',
@ -128,7 +127,6 @@ export const DEFAULT_REDACTED_PATHS = [
'activities[*].valueInBaseCurrency',
'balance',
'balanceInBaseCurrency',
'balances[*].account.balance',
'balances[*].account.comment',
'balances[*].value',
'balances[*].valueInBaseCurrency',

7
libs/common/src/lib/dtos/create-account.dto.ts

@ -12,8 +12,13 @@ import {
import { isString } from 'lodash';
export class CreateAccountDto {
/**
* The initial balance, stored as the account balance of today.
* Optional because callers may instead supply the full history via `balances`.
*/
@IsNumber()
balance: number;
@IsOptional()
balance?: number;
@IsOptional()
@IsString()

7
libs/common/src/lib/dtos/update-account.dto.ts

@ -12,8 +12,13 @@ import {
import { isString } from 'lodash';
export class UpdateAccountDto {
/**
* The balance, stored as the account balance of today.
* Optional because the account balances are the source of truth.
*/
@IsNumber()
balance: number;
@IsOptional()
balance?: number;
@IsOptional()
@IsString()

25
libs/common/src/lib/helper.spec.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config';
import {
extractNumberFromString,
getCountryCodeFromCurrency,
getNumberFormatGroup,
getStringOrNull,
getStringOrUndefined,
@ -77,6 +78,30 @@ describe('Helper', () => {
});
});
describe('Get country code from currency', () => {
it('ISO 4217 currency code', () => {
expect(getCountryCodeFromCurrency('CHF')).toEqual('CH');
expect(getCountryCodeFromCurrency('USD')).toEqual('US');
});
it('Currency of the European Union', () => {
expect(getCountryCodeFromCurrency('EUR')).toEqual('EU');
});
it('Derived currency', () => {
expect(getCountryCodeFromCurrency('GBp')).toEqual('GB');
});
it('Supranational currency', () => {
expect(getCountryCodeFromCurrency('XAU')).toEqual('');
expect(getCountryCodeFromCurrency('XOF')).toEqual('');
});
it('Empty currency', () => {
expect(getCountryCodeFromCurrency('')).toEqual('');
});
});
describe('Get number format group', () => {
let languageGetter: jest.SpyInstance<string, [], any>;

11
libs/common/src/lib/helper.ts

@ -279,6 +279,17 @@ export function getCurrencyFromSymbol(aSymbol = '') {
return aSymbol.replace(DEFAULT_CURRENCY, '');
}
export function getCountryCodeFromCurrency(aCurrency = '') {
// An ISO 4217 currency code is composed of the ISO 3166-1 alpha-2 country
// code and the initial of the currency itself, except for the supranational
// currencies, which are prefixed with X (like XAU or XOF)
if (aCurrency.startsWith('X')) {
return '';
}
return aCurrency.slice(0, 2).toUpperCase();
}
export function getCountryName({ code }: { code: string }): string {
try {
return (

1
libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts

@ -2,5 +2,6 @@ import { InvestmentItem } from '../investment-item.interface';
export interface PortfolioInvestmentsResponse {
investments: InvestmentItem[];
savingsRate?: number;
streaks: { currentStreak: number; longestStreak: number };
}

12
libs/common/src/lib/permissions.ts

@ -1,6 +1,6 @@
import { UserWithSettings } from '@ghostfolio/common/types';
import { Role } from '@prisma/client';
import { Access, Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
@ -198,17 +198,17 @@ export function hasPermission(
}
export function hasReadRestrictedAccessPermission({
impersonationId,
user
accesses = [],
impersonationId
}: {
impersonationId: string;
user: UserWithSettings;
accesses?: Pick<Access, 'id' | 'permissions'>[];
impersonationId: string | null;
}) {
if (!impersonationId) {
return false;
}
const access = user?.accessesGet?.find(({ id }) => {
const access = accesses.find(({ id }) => {
return id === impersonationId;
});

5
libs/common/src/lib/types/account-with-balance.type.ts

@ -0,0 +1,5 @@
import { Account as AccountModel } from '@prisma/client';
export type AccountWithBalance = AccountModel & {
balance: number;
};

6
libs/common/src/lib/types/account-with-value.type.ts

@ -1,6 +1,8 @@
import { Account as AccountModel, Platform, Tag } from '@prisma/client';
import { Platform, Tag } from '@prisma/client';
export type AccountWithValue = AccountModel & {
import { AccountWithBalance } from './account-with-balance.type';
export type AccountWithValue = AccountWithBalance & {
activitiesCount: number;
allocationInPercentage: number;
balanceInBaseCurrency: number;

2
libs/common/src/lib/types/index.ts

@ -1,5 +1,6 @@
import type { AccessType } from './access-type.type';
import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { AccountWithBalance } from './account-with-balance.type';
import type { AccountWithPlatform } from './account-with-platform.type';
import type { AccountWithValue } from './account-with-value.type';
import type { AiPromptMode } from './ai-prompt-mode.type';
@ -28,6 +29,7 @@ import type { ViewMode } from './view-mode.type';
export type {
AccessType,
AccessWithGranteeUser,
AccountWithBalance,
AccountWithPlatform,
AccountWithValue,
AiPromptMode,

26
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -50,13 +50,12 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.platform?.url) {
<gf-entity-logo
class="d-inline d-sm-none mr-1"
[tooltip]="element.platform?.name"
[url]="element.platform.url"
/>
}
<gf-entity-logo
class="d-inline d-sm-none mr-1"
[hasPlaceholder]="true"
[tooltip]="element.platform?.name"
[url]="element.platform?.url"
/>
<span>{{ element.name }}</span>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
@ -98,13 +97,12 @@
mat-cell
>
<div class="d-flex">
@if (element.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="element.platform?.name"
[url]="element.platform.url"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="element.platform?.name"
[url]="element.platform?.url"
/>
<span>{{ element.platform?.name }}</span>
</div>
</td>

20
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -1,3 +1,5 @@
import { AccountWithValue } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
@ -14,16 +16,18 @@ import { NotificationService } from '../notifications';
import { GfValueComponent } from '../value';
import { GfAccountsTableComponent } from './accounts-table.component';
const accounts = [
const accounts: AccountWithValue[] = [
{
activitiesCount: 0,
allocationInPercentage: null,
allocationInPercentage: 0.002574748676949956,
balance: 278,
balanceInBaseCurrency: 278,
comment: null,
createdAt: new Date('2025-06-01T06:52:49.063Z'),
currency: 'USD',
dividendInBaseCurrency: 0,
id: '460d7401-ca43-4ed4-b08e-349f1822e9db',
interestInBaseCurrency: 0,
name: 'Coinbase Account',
platform: {
id: '8dc24b88-bb92-4152-af25-fe6a31643e26',
@ -38,13 +42,15 @@ const accounts = [
},
{
activitiesCount: 0,
allocationInPercentage: null,
allocationInPercentage: 0.11114023065971035,
balance: 12000,
balanceInBaseCurrency: 12000,
comment: null,
createdAt: new Date('2025-06-01T06:48:53.055Z'),
currency: 'USD',
dividendInBaseCurrency: 0,
id: '6d773e31-0583-4c85-a247-e69870b4f1ee',
interestInBaseCurrency: 0,
name: 'Private Banking Account',
platform: {
id: '43e8fcd1-5b79-4100-b678-d2229bd1660d',
@ -59,13 +65,15 @@ const accounts = [
},
{
activitiesCount: 12,
allocationInPercentage: null,
allocationInPercentage: 0.8862850206633397,
balance: 150.2,
balanceInBaseCurrency: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
dividendInBaseCurrency: 0,
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
interestInBaseCurrency: 0,
name: 'Trading Account',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
@ -73,10 +81,10 @@ const accounts = [
url: 'https://interactivebrokers.com'
},
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
valueInBaseCurrency: 95693.70321466809,
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
value: 95693.70321466809
value: 95693.70321466809,
valueInBaseCurrency: 95693.70321466809
}
];

14
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -4,6 +4,7 @@ import {
getLowercase,
isAccountExcluded
} from '@ghostfolio/common/helper';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -24,7 +25,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Account } from '@prisma/client';
import { addIcons } from 'ionicons';
import {
arrowRedoOutline,
@ -55,7 +55,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './accounts-table.component.html'
})
export class GfAccountsTableComponent {
public readonly accounts = input.required<Account[]>();
public readonly accounts = input.required<AccountWithValue[]>();
public readonly activitiesCount = input<number>();
public readonly baseCurrency = input<string>();
public readonly hasPermissionToOpenDetails = input(true);
@ -71,12 +71,12 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>();
public readonly accountToUpdate = output<Account>();
public readonly accountToUpdate = output<AccountWithValue>();
public readonly transferBalance = output<void>();
public readonly sort = viewChild.required(MatSort);
protected readonly dataSource = new MatTableDataSource<Account>([]);
protected readonly dataSource = new MatTableDataSource<AccountWithValue>([]);
protected readonly displayedColumns = computed(() => {
const columns = ['status', 'account', 'platform'];
@ -141,7 +141,9 @@ export class GfAccountsTableComponent {
});
}
protected isExcluded(account: Account & { tags?: { id: string }[] }) {
protected isExcluded(
account: AccountWithValue & { tags?: { id: string }[] }
) {
return isAccountExcluded(account);
}
@ -173,7 +175,7 @@ export class GfAccountsTableComponent {
this.transferBalance.emit();
}
protected onUpdateAccount(aAccount: Account) {
protected onUpdateAccount(aAccount: AccountWithValue) {
this.accountToUpdate.emit(aAccount);
}
}

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

@ -356,13 +356,12 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
@if (element.account?.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="element.account?.platform?.name"
[url]="element.account?.platform?.url"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="element.account?.platform?.name"
[url]="element.account?.platform?.url"
/>
<span class="d-none d-lg-block text-nowrap">{{
element.account?.name
}}</span>

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

@ -39,7 +39,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
@ -105,7 +104,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
@ -171,7 +169,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
@ -237,7 +234,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
@ -303,7 +299,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',

10
libs/ui/src/lib/currency-selector/currency-selector.component.html

@ -1,9 +1,14 @@
@if (emojiFlagOfSelectedCurrency) {
<span class="flex-shrink-0 mr-1">{{ emojiFlagOfSelectedCurrency }}</span>
}
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="currencyAutocomplete"
[matAutocompleteConnectedTo]="autocompleteOrigin"
/>
<mat-autocomplete
@ -12,7 +17,10 @@
>
@for (currency of filteredCurrencies; track currency) {
<mat-option class="line-height-1" [value]="currency">
{{ currency }}
<span class="align-items-center d-flex">
<span class="mr-1">{{ getEmojiFlagFromCurrency(currency) }}</span>
<span>{{ currency }}</span>
</span>
</mat-option>
}
</mat-autocomplete>

3
libs/ui/src/lib/currency-selector/currency-selector.component.scss

@ -1,3 +0,0 @@
:host {
display: block;
}

99
libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts

@ -0,0 +1,99 @@
import { ANIMATION_MODULE_TYPE } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import '@angular/localize/init';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { GfCurrencySelectorComponent } from './currency-selector.component';
const CURRENCIES = [
'AUD',
'CHF',
'EUR',
'GBP',
'GBp',
'JPY',
'USD',
'XAU',
'ZAR'
];
const meta: Meta<GfCurrencySelectorComponent> = {
title: 'Currency Selector',
component: GfCurrencySelectorComponent,
decorators: [
moduleMetadata({
imports: [
GfCurrencySelectorComponent,
MatFormFieldModule,
ReactiveFormsModule
],
providers: [
{
provide: ANIMATION_MODULE_TYPE,
useValue: 'NoopAnimations'
}
]
})
],
render: ({ currencies, value }) => {
return {
props: {
currencies,
formGroup: new FormGroup({
currency: new FormControl(value)
})
},
template: `
<form [formGroup]="formGroup">
<mat-form-field appearance="outline" class="w-100">
<mat-label>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</form>
`
};
}
};
export default meta;
type Story = StoryObj<GfCurrencySelectorComponent>;
export const Default: Story = {
args: {
currencies: CURRENCIES,
value: 'CHF'
}
};
export const CurrencyOfEuropeanUnion: Story = {
args: {
currencies: CURRENCIES,
value: 'EUR'
}
};
export const DerivedCurrency: Story = {
args: {
currencies: CURRENCIES,
value: 'GBp'
}
};
export const SupranationalCurrency: Story = {
args: {
currencies: CURRENCIES,
value: 'XAU'
}
};
export const WithoutValue: Story = {
args: {
currencies: CURRENCIES,
value: null
}
};

30
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -1,3 +1,8 @@
import {
getCountryCodeFromCurrency,
getEmojiFlag
} from '@ghostfolio/common/helper';
import { FocusMonitor } from '@angular/cdk/a11y';
import {
CUSTOM_ELEMENTS_SCHEMA,
@ -24,9 +29,11 @@ import {
import {
MatAutocomplete,
MatAutocompleteModule,
MatAutocompleteOrigin,
MatOption
} from '@angular/material/autocomplete';
import {
MAT_FORM_FIELD,
MatFormFieldControl,
MatFormFieldModule
} from '@angular/material/form-field';
@ -39,7 +46,8 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
'[id]': 'id',
class: 'align-items-center d-flex'
},
imports: [
FormsModule,
@ -56,7 +64,6 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-currency-selector',
styleUrls: ['./currency-selector.component.scss'],
templateUrl: 'currency-selector.component.html'
})
export class GfCurrencySelectorComponent
@ -72,6 +79,7 @@ export class GfCurrencySelectorComponent
public readonly formControlName = input.required<string>();
private readonly destroyRef = inject(DestroyRef);
private readonly formField = inject(MAT_FORM_FIELD);
private readonly input = viewChild.required(MatInput);
public constructor(
@ -86,8 +94,20 @@ export class GfCurrencySelectorComponent
this.controlType = 'currency-selector';
}
public get autocompleteOrigin(): MatAutocompleteOrigin {
return { elementRef: this.formField.getConnectedOverlayOrigin() };
}
public get emojiFlagOfSelectedCurrency() {
const selectedCurrency = this.currencies().find((currency) => {
return currency === this.control.value;
});
return this.getEmojiFlagFromCurrency(selectedCurrency);
}
public override get empty() {
return this.input().empty;
return !this.control.value;
}
public override set value(value: string | null) {
@ -99,6 +119,10 @@ export class GfCurrencySelectorComponent
this.input().focus();
}
public getEmojiFlagFromCurrency(aCurrency = '') {
return getEmojiFlag(getCountryCodeFromCurrency(aCurrency));
}
public ngOnInit() {
if (this.disabled) {
this.control.disable();

7
libs/ui/src/lib/entity-logo/entity-logo.component.html

@ -1,8 +1,11 @@
@if (src) {
@if (src && !hasError) {
<img
onerror="this.style.display = 'none'"
class="logo"
[class.large]="size === 'large'"
[src]="src"
[title]="tooltip ? tooltip : ''"
(error)="onError()"
/>
} @else if (hasPlaceholder) {
<div class="logo placeholder" [class.large]="size === 'large'"></div>
}

12
libs/ui/src/lib/entity-logo/entity-logo.component.scss

@ -2,7 +2,7 @@
align-items: center;
display: flex;
img {
.logo {
border-radius: 0.2rem;
height: 0.8rem;
width: 0.8rem;
@ -11,5 +11,15 @@
height: 1.4rem;
width: 1.4rem;
}
&.placeholder {
border: 1px solid rgba(var(--dark-dividers));
}
}
}
:host-context(.theme-dark) {
.placeholder {
border-color: rgba(var(--light-dividers));
}
}

16
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -44,3 +44,19 @@ export const LogoByUrl: Story = {
url: 'https://ghostfol.io'
}
};
export const Placeholder: Story = {
args: {
hasPlaceholder: true,
size: 'large'
}
};
export const PlaceholderOnError: Story = {
args: {
hasPlaceholder: true,
size: 'large',
tooltip: 'Unknown',
url: 'https://unknown.ghostfol.io'
}
};

12
libs/ui/src/lib/entity-logo/entity-logo.component.ts

@ -18,18 +18,22 @@ import { DataSource } from '@prisma/client';
})
export class GfEntityLogoComponent implements OnChanges {
@Input() dataSource: DataSource;
@Input() hasPlaceholder = false;
@Input() size: 'large';
@Input() symbol: string;
@Input() tooltip: string;
@Input() url: string;
public src: string;
public hasError = false;
public src?: string;
public constructor(
private readonly imageSourceService: EntityLogoImageSourceService
) {}
public ngOnChanges() {
this.hasError = false;
if (this.dataSource && this.symbol) {
this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({
dataSource: this.dataSource,
@ -37,6 +41,12 @@ export class GfEntityLogoComponent implements OnChanges {
});
} else if (this.url) {
this.src = this.imageSourceService.getLogoUrlByUrl(this.url);
} else {
this.src = undefined;
}
}
public onError() {
this.hasError = true;
}
}

7
libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts

@ -2,6 +2,9 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
// Resolves like a logo of an unknown entity, but fails to load
const UNAVAILABLE_LOGO_URL = 'data:image/png;base64,unavailable';
export class EntityLogoImageSourceServiceMock {
public getLogoUrlByAssetProfileIdentifier({
dataSource,
@ -11,7 +14,7 @@ export class EntityLogoImageSourceServiceMock {
return 'data:image/png;base64,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';
}
return '';
return UNAVAILABLE_LOGO_URL;
}
public getLogoUrlByUrl(url: string) {
@ -19,6 +22,6 @@ export class EntityLogoImageSourceServiceMock {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEU2z8v////x/Pspzcn0/Px73drR8/KC3tz6/v6e5eOl5+WM4d7n+PhI08/i9/eu6edV1dJk2NVx2teV4+G87evvttLSAAABDElEQVRYhe2V3Y6EIAyFrQXlTwHd93/WpeBsdiKbtF7tJJwbCKFfDlKO0zQ0NPQJwqrn1ZO2zlk9PWPgusClRcsJmHb4pWUTItDDu4zMBJ5w0yog4HGvB0gCgOoBZrYFdL16AMsmmD5AcQ3ofj3AwbOAX38BIhMw02ZjtQ+tLnht66mCAGA2eka1G3eabUZwD/PLbeuHenKMBODVV0Dru/TTQLgKAc1BvQ/9yAEkOnlon66oehEBIHp7dbSyPoIc0NEADMDnAa4wAvVK+CADRGx/VpNSi+iF3jMTUH4rJQ0aISPmVk+JoJiZeJw1QnaqL2OmWKSFkxnrZWsbXK4TzO5tmS+8TYaGhv6xvgEEfAgHGc7HRgAAAABJRU5ErkJggg==';
}
return '';
return UNAVAILABLE_LOGO_URL;
}
}

13
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html

@ -7,13 +7,12 @@
@for (account of accounts(); track account.id) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url ?? ''"
/>
}
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url ?? ''"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>

4
package-lock.json

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

2
package.json

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

2
prisma/migrations/20260805120000_removed_balance_from_account/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "balance";

1
prisma/schema.prisma

@ -27,7 +27,6 @@ model Access {
model Account {
activities Order[]
balance Float @default(0)
balances AccountBalance[]
comment String?
createdAt DateTime @default(now())

1
test/import/not-ok/invalid-platform.json

@ -5,7 +5,6 @@
},
"accounts": [
{
"balance": 0,
"balances": [],
"currency": "USD",
"id": "e62be662-a2c8-4cff-8b79-dc0a46576659",

1
test/import/ok/500-activities.json

@ -5,7 +5,6 @@
},
"accounts": [
{
"balance": 2000,
"currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"name": "My Online Trading Account",

1
test/import/ok/derived-currency.json

@ -5,7 +5,6 @@
},
"accounts": [
{
"balance": 2000,
"currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"name": "My Online Trading Account",

1
test/import/ok/sample.json

@ -5,7 +5,6 @@
},
"accounts": [
{
"balance": 2000,
"balances": [
{
"date": "2024-12-31T00:00:00.000Z",

Loading…
Cancel
Save