Compare commits

..

33 Commits

Author SHA1 Message Date
0f9455cf02 Release 2.47.0 (#2951) 2024-02-03 09:44:29 +01:00
d4afa03505 Bugfix/fix rendering issue with date range selector of assistant (#2950)
* Improve click handling

* Improve locales

* Update changelog
2024-02-03 09:42:50 +01:00
c9237146e2 Feature/add investment value to chart (#2948)
* Add investment value to chart

* Update changelog
2024-02-03 09:23:19 +01:00
faad65b6f3 Update translations (#2940)
* Update translations

* Update changelog
2024-02-02 20:42:12 +01:00
e459c72100 Update OSS friends (#2942) 2024-02-01 20:54:25 +01:00
a8add30125 Feature/upgrade prettier to version 3.2.4 (#2928)
* Upgrade prettier to version 3.2.4

* Update changelog
2024-01-31 18:12:09 +01:00
b535aee91d Remove reference to Internet Identity (#2946) 2024-01-31 17:19:05 +01:00
4434d0315f Remove unused timeline calculation (#2947) 2024-01-30 20:56:41 +01:00
8b10695353 Feature/only show used tags in tag selector of assistant (#2943)
* Only show used tags in tag selector

* Update changelog
2024-01-29 19:53:47 +01:00
e82dcc8ace Feature/fix export in lazy-loaded activities table (#2939)
* Fix export in lazy-loaded activities table

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-01-29 19:37:09 +01:00
6dcb0d8583 Release 2.46.0 (#2938) 2024-01-28 10:00:07 +01:00
40b6777814 Add upgrade plan button (#2937) 2024-01-28 09:58:38 +01:00
25deba16df Feature/add reset filters button to assistant (#2936)
* Add reset filters button

* Update changelog
2024-01-28 09:47:28 +01:00
be93ca8968 Feature/migrate allocations page to work with filters of assistant (#2933)
* Migrate portfolio allocations to work with filters of assistant

* Update changelog
2024-01-28 09:20:32 +01:00
0436cc6487 Migrate ngx-skeleton-loader components to self-closing tags (#2935) 2024-01-28 08:51:02 +01:00
857708dc4d Migrate gf-* components to self-closing tags (#2934) 2024-01-28 08:50:43 +01:00
1ca4f885b0 Feature/migrate holdings page to work with filters of assistant (#2932)
* Migrate portfolio holdings to work with filters of assistant

* Update changelog
2024-01-27 19:28:13 +01:00
c9368c5cf2 Release 2.45.0 (#2931) 2024-01-27 10:54:35 +01:00
29423efea3 Update translations (#2930) 2024-01-27 10:53:19 +01:00
f3ee99fb2b Feature/extend assistant by account selector (#2929)
* Add account selector to assistant

* Update changelog
2024-01-27 10:48:46 +01:00
3df8810412 Feature/Add support to grant private access with permissions (#2870)
* Add support to grant private access with permissions

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-01-27 09:44:13 +01:00
b8ca88c6df Add auto-renewal (#2920) 2024-01-27 08:46:27 +01:00
2c068c412d Feature/migrate tag selector to form group in assistant (#2926)
* Introduce filter form group

* Update changelog
2024-01-27 08:45:55 +01:00
9fdbd22cb5 Bugfix/fix activities import for manual data source (#2923)
* Fix import

* Update changelog
2024-01-27 08:41:45 +01:00
8f5f4c5875 Feature/format name in eod historical data service (#2922)
* Format name

* Update changelog
2024-01-26 22:37:47 +01:00
50fb82a6e6 Extend personal finance tools (#2925) 2024-01-26 22:37:26 +01:00
2c10cd7edf Bugfix/remove holdings with incomplete data from top3 bottom3 performers (#2921)
* Remove holdings with incomplete data

* Update changelog
2024-01-26 08:35:23 +01:00
bbde86c66e Feature/improve language localization for de 20240124 (#2918)
* Update translations

* Update changelog
2024-01-25 21:11:07 +01:00
73c0843d51 Feature/add permissions to access model (#2833)
* Add permissions to Access model

* Update changelog
2024-01-24 19:23:58 +01:00
04fc2cd3e1 Release 2.44.0 (#2917) 2024-01-24 12:26:36 +01:00
b39c97ab9f Bugfix/improve validation for non numeric results in eod service (#2916)
* Improve validation of non-numeric numbers

* Update changelog
2024-01-24 12:24:38 +01:00
1dd5e9c787 Release 2.43.1 (#2914) 2024-01-23 20:46:37 +01:00
a9985b65b8 Adjust Dockerfile to enable healthcheck (#2913) 2024-01-23 20:44:57 +01:00
137 changed files with 6063 additions and 3314 deletions

View File

@ -5,7 +5,57 @@ 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).
## 2.43.0 - 2024-01-23
## 2.47.0 - 2024-02-02
### Changed
- Improved the tag selector to only show used tags in the assistant (experimental)
- Improved the language localization for German (`de`)
- Upgraded `prettier` from version `3.2.1` to `3.2.4`
### Fixed
- Fixed a rendering issue caused by the date range selector in the assistant (experimental)
- Fixed an issue with the currency conversion in the investment timeline
- Fixed the export in the lazy-loaded activities table on the portfolio activities page (experimental)
## 2.46.0 - 2024-01-28
### Added
- Added a button to reset the active filters in the assistant (experimental)
### Changed
- Migrated the portfolio allocations to work with the filters of the assistant (experimental)
- Migrated the portfolio holdings to work with the filters of the assistant (experimental)
## 2.45.0 - 2024-01-27
### Added
- Extended the assistant by an account selector (experimental)
- Added support to grant private access with permissions (experimental)
- Added `permissions` to the `Access` model
### Changed
- Migrated the tag selector to a form group in the assistant (experimental)
- Formatted the name in the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the import for activities with `MANUAL` data source and type `FEE`, `INTEREST`, `ITEM` or `LIABILITY`
- Removed holdings with incomplete data from the _Top 3_ and _Bottom 3_ performers on the analysis page
## 2.44.0 - 2024-01-24
### Fixed
- Improved the validation for non-numeric results in the _EOD Historical Data_ service
## 2.43.1 - 2024-01-23
### Added

View File

@ -13,7 +13,6 @@ COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \
curl \
g++ \
git \
make \
@ -53,6 +52,7 @@ RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:18-slim
RUN apt update && apt install -y \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*

View File

@ -42,23 +42,27 @@ export class AccessController {
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => {
if (GranteeUser) {
return {
alias,
id,
permissions,
grantee: GranteeUser?.id,
type: 'PRIVATE'
};
}
return {
alias: access.alias,
grantee: access.GranteeUser?.id,
id: access.id,
type: 'RESTRICTED_VIEW'
alias,
id,
permissions,
grantee: 'Public',
type: 'PUBLIC'
};
}
return {
alias: access.alias,
grantee: 'Public',
id: access.id,
type: 'PUBLIC'
};
});
);
}
@HasPermission(permissions.createAccess)
@ -83,6 +87,7 @@ export class AccessController {
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
User: { connect: { id: this.request.user.id } }
});
} catch {

View File

@ -1,4 +1,5 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@ -9,7 +10,7 @@ export class CreateAccessDto {
@IsUUID()
granteeUserId?: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
@IsString()
type?: 'PUBLIC';
permissions?: AccessPermission[];
}

View File

@ -1,4 +1,5 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
@ -10,6 +11,7 @@ import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly apiService: ApiService,
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -17,10 +19,20 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export(
@Query('activityIds') activityIds?: string[]
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[],
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});

View File

@ -1,6 +1,7 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -12,6 +13,7 @@ import { ExportService } from './export.service';
@Module({
imports: [
AccountModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -1,7 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { Export } from '@ghostfolio/common/interfaces';
import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
@ -13,10 +13,12 @@ export class ExportService {
public async export({
activityIds,
filters,
userCurrency,
userId
}: {
activityIds?: string[];
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<Export> {
@ -42,6 +44,7 @@ export class ExportService {
);
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,

View File

@ -575,7 +575,7 @@ export class ImportService {
for (const [
index,
{ currency, dataSource, symbol }
{ currency, dataSource, symbol, type }
] of uniqueActivitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error(
@ -583,28 +583,33 @@ export class ImportService {
);
}
const assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol];
const assetProfile = {
currency,
...(
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol]
};
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

View File

@ -1,8 +0,0 @@
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
import Big from 'big.js';
export interface TimelineInfoInterface {
maxNetPerformance: Big;
minNetPerformance: Big;
timelinePeriods: TimelinePeriod[];
}

View File

@ -1,9 +0,0 @@
import Big from 'big.js';
export interface TimelinePeriod {
date: string;
grossPerformance: Big;
investment: Big;
netPerformance: Big;
value: Big;
}

View File

@ -1,6 +0,0 @@
export type Accuracy = 'day' | 'month' | 'year';
export interface TimelineSpecification {
accuracy: Accuracy;
start: string;
}

View File

@ -68,14 +68,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
@ -135,7 +141,8 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
});
});

View File

@ -57,14 +57,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
@ -123,7 +129,8 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
{ date: '2021-11-01', investment: 273.2 },
{ date: '2021-12-01', investment: 0 }
]);
});
});

View File

@ -81,14 +81,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
@ -155,42 +161,43 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2015-02-01', investment: new Big('0') },
{ date: '2015-03-01', investment: new Big('0') },
{ date: '2015-04-01', investment: new Big('0') },
{ date: '2015-05-01', investment: new Big('0') },
{ date: '2015-06-01', investment: new Big('0') },
{ date: '2015-07-01', investment: new Big('0') },
{ date: '2015-08-01', investment: new Big('0') },
{ date: '2015-09-01', investment: new Big('0') },
{ date: '2015-10-01', investment: new Big('0') },
{ date: '2015-11-01', investment: new Big('0') },
{ date: '2015-12-01', investment: new Big('0') },
{ date: '2016-01-01', investment: new Big('0') },
{ date: '2016-02-01', investment: new Big('0') },
{ date: '2016-03-01', investment: new Big('0') },
{ date: '2016-04-01', investment: new Big('0') },
{ date: '2016-05-01', investment: new Big('0') },
{ date: '2016-06-01', investment: new Big('0') },
{ date: '2016-07-01', investment: new Big('0') },
{ date: '2016-08-01', investment: new Big('0') },
{ date: '2016-09-01', investment: new Big('0') },
{ date: '2016-10-01', investment: new Big('0') },
{ date: '2016-11-01', investment: new Big('0') },
{ date: '2016-12-01', investment: new Big('0') },
{ date: '2017-01-01', investment: new Big('0') },
{ date: '2017-02-01', investment: new Big('0') },
{ date: '2017-03-01', investment: new Big('0') },
{ date: '2017-04-01', investment: new Big('0') },
{ date: '2017-05-01', investment: new Big('0') },
{ date: '2017-06-01', investment: new Big('0') },
{ date: '2017-07-01', investment: new Big('0') },
{ date: '2017-08-01', investment: new Big('0') },
{ date: '2017-09-01', investment: new Big('0') },
{ date: '2017-10-01', investment: new Big('0') },
{ date: '2017-11-01', investment: new Big('0') },
{ date: '2017-12-01', investment: new Big('-14156.4') }
{ date: '2015-01-01', investment: 637.0853345999999 },
{ date: '2015-02-01', investment: 0 },
{ date: '2015-03-01', investment: 0 },
{ date: '2015-04-01', investment: 0 },
{ date: '2015-05-01', investment: 0 },
{ date: '2015-06-01', investment: 0 },
{ date: '2015-07-01', investment: 0 },
{ date: '2015-08-01', investment: 0 },
{ date: '2015-09-01', investment: 0 },
{ date: '2015-10-01', investment: 0 },
{ date: '2015-11-01', investment: 0 },
{ date: '2015-12-01', investment: 0 },
{ date: '2016-01-01', investment: 0 },
{ date: '2016-02-01', investment: 0 },
{ date: '2016-03-01', investment: 0 },
{ date: '2016-04-01', investment: 0 },
{ date: '2016-05-01', investment: 0 },
{ date: '2016-06-01', investment: 0 },
{ date: '2016-07-01', investment: 0 },
{ date: '2016-08-01', investment: 0 },
{ date: '2016-09-01', investment: 0 },
{ date: '2016-10-01', investment: 0 },
{ date: '2016-11-01', investment: 0 },
{ date: '2016-12-01', investment: 0 },
{ date: '2017-01-01', investment: 0 },
{ date: '2017-02-01', investment: 0 },
{ date: '2017-03-01', investment: 0 },
{ date: '2017-04-01', investment: 0 },
{ date: '2017-05-01', investment: 0 },
{ date: '2017-06-01', investment: 0 },
{ date: '2017-07-01', investment: 0 },
{ date: '2017-08-01', investment: 0 },
{ date: '2017-09-01', investment: 0 },
{ date: '2017-10-01', investment: 0 },
{ date: '2017-11-01', investment: 0 },
{ date: '2017-12-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
});
});

View File

@ -70,14 +70,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2023-01-03')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
@ -137,7 +143,31 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: new Big('89.12') }
{ date: '2023-01-01', investment: 82.329056 },
{
date: '2023-02-01',
investment: 0
},
{
date: '2023-03-01',
investment: 0
},
{
date: '2023-04-01',
investment: 0
},
{
date: '2023-05-01',
investment: 0
},
{
date: '2023-06-01',
investment: 0
},
{
date: '2023-07-01',
investment: 0
}
]);
});
});

View File

@ -45,14 +45,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: new Date()
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();

View File

@ -68,14 +68,20 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
@ -137,8 +143,8 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 }
]);
});
});

View File

@ -68,9 +68,9 @@ describe('PortfolioCalculator', () => {
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData(
parseDate('2022-03-07')
);
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
@ -78,13 +78,16 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(chartData[0]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
@ -97,6 +100,7 @@ describe('PortfolioCalculator', () => {
expect(chartData[chartData.length - 1]).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
@ -163,8 +167,8 @@ describe('PortfolioCalculator', () => {
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-171.46') }
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});

View File

@ -1,9 +1,10 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
HistoricalDataItem,
InvestmentItem,
ResponseError,
SymbolMetrics,
TimelinePosition
@ -15,41 +16,19 @@ import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isSameYear,
max,
min,
set,
subDays
} from 'date-fns';
import {
cloneDeep,
first,
flatten,
isNumber,
last,
sortBy,
uniq
} from 'lodash';
import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
@ -193,7 +172,15 @@ export class PortfolioCalculator {
this.transactionPoints = transactionPoints;
}
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
public async getChartData({
end = new Date(Date.now()),
start,
step = 1
}: {
end?: Date;
start: Date;
step?: number;
}): Promise<HistoricalDataItem[]> {
const symbols: { [symbol: string]: boolean } = {};
const transactionPointsBeforeEndDate =
@ -217,13 +204,15 @@ export class PortfolioCalculator {
dates.push(resetHours(end));
}
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
symbols[item.symbol] = true;
if (transactionPointsBeforeEndDate.length > 0) {
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
symbols[item.symbol] = true;
}
}
const { dataProviderInfos, values: marketSymbols } =
@ -262,6 +251,7 @@ export class PortfolioCalculator {
const accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
@ -277,7 +267,8 @@ export class PortfolioCalculator {
[symbol: string]: {
currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big };
investmentValues: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
@ -290,7 +281,8 @@ export class PortfolioCalculator {
const {
currentValues,
currentValuesWithCurrencyEffect,
investmentValues,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
@ -310,7 +302,8 @@ export class PortfolioCalculator {
valuesBySymbol[symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValues,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
@ -332,8 +325,13 @@ export class PortfolioCalculator {
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const investmentValue =
symbolValues.investmentValues?.[dateString] ?? new Big(0);
const investmentValueAccumulated =
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
const investmentValueAccumulatedWithCurrencyEffect =
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
dateString
] ?? new Big(0);
const investmentValueWithCurrencyEffect =
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
@ -355,6 +353,10 @@ export class PortfolioCalculator {
] ?? new Big(0);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
@ -365,11 +367,11 @@ export class PortfolioCalculator {
totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValue),
).add(investmentValueAccumulated),
totalInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
).add(investmentValueAccumulatedWithCurrencyEffect),
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
@ -392,6 +394,7 @@ export class PortfolioCalculator {
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
@ -421,6 +424,8 @@ export class PortfolioCalculator {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
investmentValueWithCurrencyEffect:
investmentValueWithCurrencyEffect.toNumber(),
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
@ -685,196 +690,27 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByGroup(
groupBy: GroupBy
): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
public getInvestmentsByGroup({
data,
groupBy
}: {
data: HistoricalDataItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
const groupedData: { [dateGroup: string]: Big } = {};
const investments: { date: string; investment: Big }[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameYear(parseDate(order.date), currentDate) &&
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) {
// Same group: Add up investments
investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New group: Store previous group and reset
if (currentDate) {
investments.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
currentDate = parseDate(order.date);
investmentByGroup = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current group (latest order)
investments.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
}
// Fill in the missing dates with investment = 0
const startDate = parseDate(first(this.orders).date);
const endDate = parseDate(last(this.orders).date);
const allDates: string[] = [];
currentDate = startDate;
while (currentDate <= endDate) {
allDates.push(
format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
)
for (const { date, investmentValueWithCurrencyEffect } of data) {
const dateGroup =
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4);
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus(
investmentValueWithCurrencyEffect
);
currentDate.setMonth(currentDate.getMonth() + 1);
}
for (const date of allDates) {
const existingInvestment = investments.find((investment) => {
return investment.date === date;
});
if (!existingInvestment) {
investments.push({ date, investment: new Big(0) });
}
}
return sortBy(investments, ({ date }) => {
return date;
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
let minNetPerformance = new Big(0);
let maxNetPerformance = new Big(0);
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
try {
minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
} catch {}
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
return Object.keys(groupedData).map((dateGroup) => ({
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`,
investment: groupedData[dateGroup].toNumber()
}));
}
private calculateOverallPerformance(positions: TimelinePosition[]) {
@ -983,123 +819,6 @@ export class PortfolioCalculator {
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
const { values } = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
}
});
marketSymbols = values;
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error,
'PortfolioCalculator'
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
@ -1118,17 +837,6 @@ export class PortfolioCalculator {
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private getSymbolMetrics({
end,
exchangeRates,
@ -1165,7 +873,10 @@ export class PortfolioCalculator {
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValues: { [date: string]: Big } = {};
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
@ -1207,7 +918,8 @@ export class PortfolioCalculator {
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValues: {},
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
@ -1246,7 +958,8 @@ export class PortfolioCalculator {
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValues: {},
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
@ -1639,11 +1352,15 @@ export class PortfolioCalculator {
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValues[order.date] = totalInvestment;
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesWithCurrencyEffect[order.date] =
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
@ -1801,7 +1518,8 @@ export class PortfolioCalculator {
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValues,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
@ -1823,15 +1541,4 @@ export class PortfolioCalculator {
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -74,6 +74,11 @@ export class PortfolioController {
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
@ -108,7 +113,7 @@ export class PortfolioController {
let portfolioSummary = summary;
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
@ -148,7 +153,7 @@ export class PortfolioController {
if (
hasDetails === false ||
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
@ -164,6 +169,7 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'fireWealth',
'interest',
'items',
'liabilities',
'netWorth',
@ -216,6 +222,12 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -230,7 +242,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
@ -266,6 +278,12 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -281,7 +299,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
@ -329,6 +347,12 @@ export class PortfolioController {
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -344,7 +368,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
) {

View File

@ -79,7 +79,7 @@ import {
subDays,
subYears
} from 'date-fns';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import { isEmpty, last, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
@ -293,77 +293,32 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const { items } = await this.getChart({
dateRange,
impersonationId,
portfolioOrders,
transactionPoints,
userId,
userCurrency: this.request.user.Settings.settings.baseCurrency,
withDataDecimation: false
});
let investments: InvestmentItem[];
if (groupBy) {
investments = portfolioCalculator
.getInvestmentsByGroup(groupBy)
.map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of current group
const dateOfCurrentGroup = format(
set(new Date(), {
date: 1,
month: groupBy === 'year' ? 0 : new Date().getMonth()
}),
DATE_FORMAT
);
const investmentOfCurrentGroup = investments.filter(({ date }) => {
return date === dateOfCurrentGroup;
investments = portfolioCalculator.getInvestmentsByGroup({
groupBy,
data: items
});
if (investmentOfCurrentGroup.length <= 0) {
investments.push({
date: dateOfCurrentGroup,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
investments = items.map(({ date, investmentValueWithCurrencyEffect }) => {
return {
date,
investment: investmentValueWithCurrencyEffect
};
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
}
investments = sortBy(investments, ({ date }) => {
return date;
});
const startDate = this.getStartDate(
dateRange,
parseDate(investments[0]?.date)
);
investments = investments.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
let streaks: PortfolioInvestments['streaks'];
if (savingsRate) {
@ -1448,7 +1403,8 @@ export class PortfolioService {
portfolioOrders,
transactionPoints,
userCurrency,
userId
userId,
withDataDecimation = true
}: {
dateRange?: DateRange;
impersonationId: string;
@ -1456,6 +1412,7 @@ export class PortfolioService {
transactionPoints: TransactionPoint[];
userCurrency: string;
userId: string;
withDataDecimation?: boolean;
}): Promise<HistoricalDataContainer> {
if (transactionPoints.length === 0) {
return {
@ -1481,16 +1438,18 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
let step = 1;
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
if (withDataDecimation) {
const daysInMarket = differenceInDays(new Date(), startDate);
step = Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS));
}
const items = await portfolioCalculator.getChartData({
step,
end: endDate,
start: startDate
});
return {
items,

View File

@ -38,6 +38,10 @@ export class UpdateUserSettingDto {
@IsOptional()
emergencyFund?: number;
@IsArray()
@IsOptional()
'filters.accounts'?: string[];
@IsArray()
@IsOptional()
'filters.tags'?: string[];

View File

@ -105,6 +105,24 @@ export class UserService {
return usersWithAdminRole.length > 0;
}
public hasReadRestrictedAccessPermission({
impersonationId,
user
}: {
impersonationId: string;
user: UserWithSettings;
}) {
if (!impersonationId) {
return false;
}
const access = user.Access?.find(({ id }) => {
return id === impersonationId;
});
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
}
public isRestrictedView(aUser: UserWithSettings) {
return aUser.Settings.settings.isRestrictedView ?? false;
}
@ -113,6 +131,7 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const {
Access,
accessToken,
Account,
Analytics,
@ -127,6 +146,7 @@ export class UserService {
updatedAt
} = await this.prismaService.user.findUnique({
include: {
Access: true,
Account: {
include: { Platform: true }
},
@ -138,6 +158,7 @@ export class UserService {
});
const user: UserWithSettings = {
Access,
accessToken,
Account,
authChallenge,
@ -198,18 +219,18 @@ export class UserService {
new Date(),
user.createdAt
);
let frequency = 15;
let frequency = 10;
if (daysSinceRegistration > 365) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 5;
frequency = 4;
} else if (daysSinceRegistration > 30) {
frequency = 8;
frequency = 6;
} else if (daysSinceRegistration > 15) {
frequency = 12;
frequency = 8;
}
if (Analytics?.activityCount % frequency === 1) {

View File

@ -230,6 +230,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -568,6 +572,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -926,6 +934,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1130,6 +1142,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -1,6 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { UserWithSettings } from '@ghostfolio/common/types';
import {
CallHandler,
ExecutionContext,
@ -22,13 +23,20 @@ export class RedactValuesInResponseInterceptor<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const { headers, user }: { headers: Headers; user: UserWithSettings } =
context.switchToHttp().getRequest();
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const hasReadRestrictedPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user
});
if (
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
hasReadRestrictedPermission ||
this.userService.isRestrictedView(user)
) {
data = redactAttributes({
object: data,

View File

@ -24,7 +24,7 @@ export class ApiService {
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
return [
const filters = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
@ -43,10 +43,6 @@ export class ApiService {
type: 'ASSET_SUB_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'
},
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
@ -54,5 +50,14 @@ export class ApiService {
};
})
];
if (searchQuery) {
filters.push({
id: searchQuery,
type: 'SEARCH_QUERY'
});
}
return filters;
}
}

View File

@ -62,9 +62,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.'
)?.[0]}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
symbol.split('.')?.[0]
}.json`,
{
// @ts-ignore
signal: abortController.signal
@ -104,9 +104,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
'.'
)?.[0]}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`,
{
// @ts-ignore
signal: abortController.signal

View File

@ -1,7 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -137,18 +141,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
if (quoteType === 'FUTURE') {

View File

@ -11,7 +11,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -22,6 +25,7 @@ import {
} from '@prisma/client';
import { addDays, format, isSameDay, isToday } from 'date-fns';
import got from 'got';
import { isNumber } from 'lodash';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
@ -144,10 +148,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>();
return response.reduce(
(result, historicalItem, index, array) => {
result[this.convertFromEodSymbol(symbol)][historicalItem.date] = {
marketPrice: historicalItem.close
};
(result, { close, date }, index, array) => {
if (isNumber(close)) {
result[this.convertFromEodSymbol(symbol)][date] = {
marketPrice: close
};
} else {
Logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`,
'EodHistoricalDataService'
);
}
return result;
},
@ -232,14 +243,23 @@ export class EodHistoricalDataService implements DataProviderInterface {
return lookupItem.symbol === code;
})?.currency;
result[this.convertFromEodSymbol(code)] = {
currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
if (isNumber(close)) {
result[this.convertFromEodSymbol(code)] = {
currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: this.getName(),
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
};
} else {
Logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
'EodHistoricalDataService'
);
}
return result;
},
@ -345,6 +365,18 @@ export class EodHistoricalDataService implements DataProviderInterface {
return aSymbol;
}
private formatName({ name }: { name: string }) {
if (name) {
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
return name;
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
assetClass: AssetClass;
@ -380,9 +412,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetClass,
assetSubClass,
isin,
name,
currency: this.convertCurrency(Currency),
dataSource: this.getName(),
name: this.formatName({ name }),
symbol: `${Code}.${Exchange}`
};
}

View File

@ -42,10 +42,21 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
const assetProfile: Partial<SymbolProfile> = {
dataSource: this.getName(),
symbol: aSymbol
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: this.getName(), symbol: aSymbol }
]);
if (symbolProfile) {
assetProfile.currency = symbolProfile.currency;
assetProfile.name = symbolProfile.name;
}
return assetProfile;
}
public async getDividends({}: GetDividendsParams) {

View File

@ -38,7 +38,7 @@
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
/>
</header>
<main role="main">

View File

@ -17,8 +17,13 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted View</ng-container>
@if (element.permissions.includes('READ')) {
<ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container>
} @else if (element.permissions.includes('READ_RESTRICTED')) {
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container>
}
</div>
</td>
</ng-container>

View File

@ -178,7 +178,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
this.dataService
.fetchExport(activityIds)
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({

View File

@ -4,7 +4,7 @@
[deviceType]="data.deviceType"
[title]="name"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
/>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
@ -16,7 +16,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency"
></gf-value>
/>
</div>
</div>
@ -28,7 +28,7 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
></gf-investment-chart>
/>
</div>
<div class="mb-3 row">
@ -79,7 +79,7 @@
[deviceType]="data.deviceType"
[holdings]="holdings"
[locale]="user?.settings?.locale"
></gf-holdings-table>
/>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
@ -102,7 +102,7 @@
[totalItems]="totalItems"
(export)="onExport()"
(sortChanged)="onSortChanged($event)"
></gf-activities-table-lazy>
/>
<gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
@ -115,7 +115,7 @@
[locale]="user?.settings?.locale"
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
/>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
@ -128,7 +128,7 @@
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
></gf-account-balances>
/>
</mat-tab>
</mat-tab-group>
</div>
@ -138,4 +138,4 @@
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>
/>

View File

@ -39,7 +39,7 @@
class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
/>
<span>{{ element.name }} </span>
<span
*ngIf="element.isDefault"
@ -83,7 +83,7 @@
class="mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
/>
<span>{{ element.Platform?.name }}</span>
</div>
</td>
@ -131,7 +131,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="element.balance"
></gf-value>
/>
</td>
<td
*matFooterCellDef
@ -143,7 +143,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="totalBalanceInBaseCurrency"
></gf-value>
/>
</td>
</ng-container>
@ -166,7 +166,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="element.value"
></gf-value>
/>
</td>
<td
*matFooterCellDef
@ -178,7 +178,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="totalValueInBaseCurrency"
></gf-value>
/>
</td>
</ng-container>
@ -201,7 +201,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="element.valueInBaseCurrency"
></gf-value>
/>
</td>
<td
*matFooterCellDef
@ -213,7 +213,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="totalValueInBaseCurrency"
></gf-value>
/>
</td>
</ng-container>
@ -296,4 +296,4 @@
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>

View File

@ -8,7 +8,7 @@
[showXAxis]="true"
[showYAxis]="true"
[symbol]="symbol"
></gf-line-chart>
/>
<div
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
class="d-flex"

View File

@ -6,7 +6,7 @@
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
/>
</div>
</div>
<div class="row">
@ -213,7 +213,7 @@
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>
</div>
</div>

View File

@ -50,7 +50,7 @@
[marketData]="marketDataDetails"
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
/>
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
@ -162,7 +162,7 @@
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
@ -172,7 +172,7 @@
[keys]="['name']"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>
/>
</div>
</ng-template>
</ng-container>

View File

@ -16,7 +16,7 @@
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
></gf-value>
/>
</div>
</div>
<div class="d-flex my-3">
@ -26,7 +26,7 @@
[locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount"
></gf-value>
/>
<div *ngIf="transactionCount && userCount">
{{ transactionCount / userCount | number : '1.2-2' }}
<span i18n>per User</span>
@ -39,10 +39,7 @@
<table>
<tr *ngFor="let exchangeRate of exchangeRates">
<td>
<gf-value
[locale]="user?.settings?.locale"
[value]="1"
></gf-value>
<gf-value [locale]="user?.settings?.locale" [value]="1" />
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
@ -52,7 +49,7 @@
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
></gf-value>
/>
</td>
<td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>

View File

@ -35,7 +35,7 @@
class="d-inline mr-1"
[tooltip]="element.name"
[url]="element.url"
></gf-symbol-icon>
/>
<span>{{ element.name }}</span>
</td></ng-container
>

View File

@ -46,7 +46,7 @@
class="ml-1"
[enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
></gf-premium-indicator>
/>
</div>
</td>
</ng-container>
@ -107,7 +107,7 @@
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="element.accountCount"
></gf-value>
/>
</td>
</ng-container>
@ -128,7 +128,7 @@
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="element.transactionCount"
></gf-value>
/>
</td>
</ng-container>
@ -153,7 +153,7 @@
[locale]="user?.settings?.locale"
[precision]="0"
[value]="element.engagement"
></gf-value>
/>
</td>
</ng-container>

View File

@ -7,7 +7,7 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</div>
</div>
<div class="col-md-6 col-xs-12 d-flex justify-content-end">
@ -50,7 +50,7 @@
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
/>
<canvas
#chartCanvas
class="h-100"

View File

@ -19,5 +19,5 @@
[theme]="{
height: '100%'
}"
></ngx-skeleton-loader>
/>
</div>

View File

@ -7,7 +7,7 @@
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
>
<gf-logo class="px-2" [label]="pageTitle"></gf-logo>
<gf-logo class="px-2" [label]="pageTitle" />
</a>
</div>
<span class="spacer"></span>
@ -141,7 +141,7 @@
[user]="user"
(closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)"
(selectedTagChanged)="onSelectedTagChanged($event)"
(filtersChanged)="onFiltersChanged($event)"
/>
</mat-menu>
</li>
@ -165,6 +165,32 @@
/>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container
*ngIf="
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
"
>
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex"
><span
><ng-container
*ngIf="user.subscription.offer === 'default'"
i18n
>Upgrade Plan</ng-container
>
<ng-container
*ngIf="user.subscription.offer === 'renewal'"
i18n
>Renew Plan</ng-container
></span
>
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false" /></span
></a>
<hr class="m-0" />
</ng-container>
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex">
@ -295,7 +321,7 @@
class="px-2"
[label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
></gf-logo>
/>
</a>
</div>
<span class="spacer"></span>

View File

@ -11,6 +11,7 @@ import {
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -20,11 +21,10 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { Tag } from '@prisma/client';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -162,6 +162,34 @@ export class HeaderComponent implements OnChanges {
});
}
public onFiltersChanged(filters: Filter[]) {
const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) {
let filtersType: string;
if (filter.type === 'ACCOUNT') {
filtersType = 'accounts';
} else if (filter.type === 'TAG') {
filtersType = 'tags';
}
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
}
this.dataService
.putUserSetting(userSetting)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onMenuClosed() {
this.isMenuOpen = false;
}
@ -174,20 +202,6 @@ export class HeaderComponent implements OnChanges {
this.assistantElement.initialize();
}
public onSelectedTagChanged(tag: Tag) {
this.dataService
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onSignOut() {
this.signOut.next();
}

View File

@ -7,6 +7,7 @@ import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { HeaderComponent } from './header.component';
@ -17,6 +18,7 @@ import { HeaderComponent } from './header.component';
CommonModule,
GfAssistantModule,
GfLogoModule,
GfPremiumIndicatorModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,
MatMenuModule,

View File

@ -5,7 +5,7 @@
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
/>
</div>
<div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
@ -18,7 +18,7 @@
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="user?.settings?.dateRange"
></gf-positions>
/>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">

View File

@ -18,11 +18,11 @@
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
></gf-line-chart>
/>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
></gf-fear-and-greed-index>
/>
</div>
</div>
@ -32,7 +32,7 @@
[benchmarks]="benchmarks"
[locale]="user?.settings?.locale"
[user]="user"
></gf-benchmark>
/>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
@ -41,7 +41,7 @@
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>
</div>
</div>
</div>

View File

@ -74,7 +74,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
});
this.showDetails =
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';

View File

@ -78,7 +78,7 @@
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
/>
</div>
</div>
</div>
@ -95,7 +95,7 @@
[performance]="performance"
[showDetails]="showDetails"
[unit]="unit"
></gf-portfolio-performance>
/>
<div
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
class="text-center"
@ -105,7 +105,7 @@
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
/>
</div>
</div>
</div>

View File

@ -12,7 +12,7 @@
[locale]="user?.settings?.locale"
[summary]="summary"
(emergencyFundChanged)="onChangeEmergencyFund($event)"
></gf-portfolio-summary>
/>
</mat-card-content>
</mat-card>
</div>

View File

@ -5,7 +5,7 @@
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
/>
<canvas
#chartCanvas
class="h-100"

View File

@ -2,7 +2,7 @@
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
/>
<div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column">

View File

@ -1,14 +1,12 @@
<div class="container p-0">
<div class="no-gutters row">
<div
class="status-container text-muted text-right"
(click)="onShowErrors()"
>
<div class="status-container text-muted text-right">
@if (errors?.length > 0 && !isLoading) {
<ion-icon
i18n-title
name="time-outline"
title="Oops! Our data provider partner is experiencing the hiccups."
title="Oops! A data provider is experiencing the hiccups."
(click)="onShowErrors()"
/>
}
</div>
@ -20,7 +18,7 @@
height: '4rem',
width: '15rem'
}"
></ngx-skeleton-loader>
/>
</div>
<div
class="display-4 font-weight-bold m-0 text-center value-container"
@ -43,7 +41,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
/>
</div>
<div class="col">
<gf-value
@ -53,7 +51,7 @@
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
/>
</div>
</div>
</div>

View File

@ -58,7 +58,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
duration: 1,
separator: getNumberFormatGroup(this.locale)
}).start();
} else if (this.performance?.currentValue === null) {
} else if (this.showDetails === false) {
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
@ -69,6 +69,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
separator: getNumberFormatGroup(this.locale)
}
).start();
} else {
this.value.nativeElement.innerHTML = '*****';
}
}
}

View File

@ -2,7 +2,7 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Time in Market</div>
<div class="justify-content-end">
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
<gf-value class="justify-content-end" [value]="timeInMarket" />
</div>
</div>
<div
@ -10,8 +10,8 @@
[hidden]="summary?.ordersCount === null"
>
<div class="flex-grow-1 ml-3 text-truncate" i18n>
{{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction}
other {transactions}}
{{ summary?.ordersCount }}
{summary?.ordersCount, plural, =1 {transaction} other {transactions}}
</div>
</div>
<div class="row">
@ -26,7 +26,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalBuy"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -38,7 +38,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalSell"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -53,7 +53,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.committedFunds"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -65,13 +65,17 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
<ng-container i18n>Gross Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
@ -83,7 +87,7 @@
[value]="
isLoading ? undefined : summary?.currentGrossPerformancePercent
"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -96,7 +100,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -111,13 +115,17 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3">
<ng-container i18n>Net Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
@ -127,7 +135,7 @@
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -143,7 +151,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -155,7 +163,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.items"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -176,7 +184,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.total"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -189,7 +197,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.cash"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -202,7 +210,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.assets"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -214,7 +222,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -226,7 +234,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -246,7 +254,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -261,7 +269,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -276,7 +284,7 @@
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
></gf-value>
/>
</div>
</div>
<div class="row">
@ -291,7 +299,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest"
></gf-value>
/>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
@ -303,7 +311,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend"
></gf-value>
/>
</div>
</div>
</div>

View File

@ -281,7 +281,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}
this.dataService
.fetchExport(activityIds)
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({

View File

@ -4,7 +4,7 @@
[deviceType]="data.deviceType"
[title]="SymbolProfile?.name ?? SymbolProfile?.symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
/>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
@ -16,7 +16,7 @@
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="value"
></gf-value>
/>
</div>
</div>
@ -33,7 +33,7 @@
[showXAxis]="true"
[showYAxis]="true"
[symbol]="data.symbol"
></gf-line-chart>
/>
<div class="row">
<div class="col-6 mb-3">
@ -222,7 +222,7 @@
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
@ -234,7 +234,7 @@
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>
/>
</div>
</ng-template>
</ng-container>
@ -266,7 +266,7 @@
[sortDisabled]="true"
[totalItems]="totalItems"
(export)="onExport()"
></gf-activities-table-lazy>
/>
<gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
@ -280,7 +280,7 @@
[showActions]="false"
[showNameColumn]="false"
(export)="onExport()"
></gf-activities-table>
/>
</div>
</div>
@ -314,4 +314,4 @@
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>
/>

View File

@ -18,7 +18,7 @@
[marketState]="position?.marketState"
[range]="range"
[value]="position?.netPerformancePercentage"
></gf-trend-indicator>
/>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
<ngx-skeleton-loader
@ -28,14 +28,14 @@
height: '1.2rem',
width: '12rem'
}"
></ngx-skeleton-loader>
/>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '8rem'
}"
></ngx-skeleton-loader>
/>
</div>
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
@ -50,13 +50,13 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformance"
></gf-value>
/>
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="position?.netPerformancePercentage"
></gf-value>
/>
</div>
</div>
<div class="align-items-center d-flex">

View File

@ -2,7 +2,7 @@
<div class="row no-gutters">
<div class="col">
<ng-container *ngIf="positions === undefined">
<gf-position [isLoading]="true"></gf-position>
<gf-position [isLoading]="true" />
</ng-container>
<ng-container *ngIf="positions !== undefined">
<ng-container *ngIf="hasPositions">
@ -13,7 +13,7 @@
[locale]="locale"
[position]="position"
[range]="range"
></gf-position>
/>
<gf-position
*ngFor="let position of positionsRest"
[baseCurrency]="baseCurrency"
@ -21,15 +21,13 @@
[locale]="locale"
[position]="position"
[range]="range"
></gf-position>
/>
</ng-container>
<div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</div>
</ng-container>
</div>

View File

@ -8,7 +8,7 @@
height: '2rem',
width: '2rem'
}"
></ngx-skeleton-loader>
/>
</div>
<div
*ngIf="!isLoading"
@ -26,14 +26,14 @@
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
/>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '15rem'
}"
></ngx-skeleton-loader>
/>
</div>
<div *ngIf="!isLoading" class="flex-grow-1">
<div class="h6 my-1">{{ rule?.name }}</div>

View File

@ -7,15 +7,13 @@
class="my-2 text-center"
>
<mat-card-content>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator
></mat-card-content>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</mat-card-content>
</mat-card>
<gf-rule *ngIf="rules?.length === 0" [isLoading]="true"></gf-rule>
<gf-rule *ngIf="rules?.length === 0" [isLoading]="true" />
<ng-container *ngIf="rules !== null && rules !== undefined">
<gf-rule *ngFor="let rule of rules" [rule]="rule"></gf-rule>
<gf-rule *ngFor="let rule of rules" [rule]="rule" />
</ng-container>
</div>
</div>

View File

@ -7,10 +7,7 @@
<div>
<h5 class="align-items-center d-flex justify-content-center mb-3">
<span>Ghostfolio Premium</span>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
<gf-premium-indicator class="ml-1" [enableLink]="false" />
</h5>
<div class="font-weight-normal h5 mb-3 text-center" i18n>
Are you an ambitious investor who needs the full picture?

View File

@ -37,19 +37,23 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required],
userId: [this.data.access.grantee, Validators.required]
});
this.accessForm.get('type').valueChanges.subscribe((value) => {
this.accessForm.get('type').valueChanges.subscribe((accessType) => {
const permissionsControl = this.accessForm.get('permissions');
const userIdControl = this.accessForm.get('userId');
if (value === 'PRIVATE') {
if (accessType === 'PRIVATE') {
permissionsControl.setValidators(Validators.required);
userIdControl.setValidators(Validators.required);
} else {
userIdControl.clearValidators();
}
permissionsControl.updateValueAndValidity();
userIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck();
@ -64,7 +68,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value,
granteeUserId: this.accessForm.controls['userId'].value,
type: this.accessForm.controls['type'].value
permissions: [this.accessForm.controls['permissions'].value]
};
this.dataService

View File

@ -30,9 +30,20 @@
@if (accessForm.controls['type'].value === 'PRIVATE') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label
>Ghostfolio <ng-container i18n>User ID</ng-container></mat-label
>
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if(data?.user?.settings?.isExperimentalFeatures) {
<mat-option i18n value="READ">View</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label>
Ghostfolio <ng-container i18n>User ID</ng-container>
</mat-label>
<input
formControlName="userId"
matInput

View File

@ -1,5 +1,6 @@
import { Access } from '@ghostfolio/common/interfaces';
import { Access, User } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
user: User;
}

View File

@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Access, User } from '@ghostfolio/common/interfaces';
@ -105,8 +104,10 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
data: {
access: {
alias: '',
permissions: ['READ_RESTRICTED'],
type: 'PRIVATE'
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -6,13 +6,13 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h1>
<gf-access-table
[accesses]="accesses"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
/>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"

View File

@ -5,7 +5,7 @@
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[name]="user?.subscription?.type"
></gf-membership-card>
/>
<div
*ngIf="user?.subscription?.type === 'Basic'"
class="d-flex flex-column mt-5"
@ -15,10 +15,10 @@
>
<button color="primary" mat-flat-button (click)="onCheckout()">
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
>Upgrade</ng-container
>Upgrade Plan</ng-container
>
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
>Renew</ng-container
>Renew Plan</ng-container
>
</button>
<div *ngIf="price" class="mt-1 text-center">
@ -43,8 +43,8 @@
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></a>
/>
</a>
<a
*ngIf="hasPermissionToUpdateUserSettings"
class="mx-1"

View File

@ -5,6 +5,6 @@
[theme]="{
width: '100%'
}"
></ngx-skeleton-loader>
/>
<div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>

View File

@ -15,7 +15,7 @@
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
(transferBalance)="onTransferBalance()"
></gf-accounts-table>
/>
</div>
</div>
</div>

View File

@ -61,7 +61,7 @@
class="mr-1"
[tooltip]="platformEntry.name"
[url]="platformEntry.url"
></gf-symbol-icon>
/>
<span>{{ platformEntry.name }}</span>
</span>
</mat-option>

View File

@ -17,8 +17,7 @@
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
/><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select>
@ -35,8 +34,7 @@
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
/><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select>

View File

@ -20,8 +20,8 @@
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></span>
/>
</span>
annual plan for ambitious investors who need the full picture of
their financial assets.
</p>

View File

@ -21,8 +21,8 @@
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></span>
/>
</span>
annual plan with our exclusive Black Week deal. Elevate your
financial strategy with the power of Ghostfolio designed to give you
the full picture of your assets.

View File

@ -50,11 +50,8 @@
You can sign up via the “<a [routerLink]="routerLinkRegister"
>Get Started</a
>” button at the top of the page. You have multiple options to join
Ghostfolio: Create an account with a security token, using
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>Internet Identity</a
>
or <i>Google Sign</i>. We will guide you to set up your portfolio.
Ghostfolio: Create an account with a security token or
<i>Google Sign</i>. We will guide you to set up your portfolio.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -75,11 +72,9 @@
></mat-card-header
>
<mat-card-content>
Yes, the authentication systems (via security token or
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>Internet Identity</a
>) enable you to sign in securely and anonymously to Ghostfolio. There
is no need for an e-mail address, phone number, or a username.
Yes, the authentication system via security token enables you to sign
in securely and anonymously to Ghostfolio. There is no need for an
e-mail address, phone number, or a username.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -176,6 +171,19 @@
your university e-mail address.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Does the Ghostfolio Premium subscription renew
automatically?</mat-card-title
>
</mat-card-header>
<mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
not include auto-renewal. Upon expiration, you can choose whether to
start a new subscription.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title>

View File

@ -142,7 +142,7 @@
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<p class="m-0">
Check the rate of return of your portfolio for
@ -162,7 +162,7 @@
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<p class="m-0">
Check the allocations of your portfolio by account, asset
@ -207,7 +207,7 @@
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span i18n>Market Mood</span>
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
<gf-premium-indicator class="ml-1" />
</h4>
<p class="m-0">
Check the current market mood (<a
@ -228,7 +228,7 @@
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<p class="m-0">
Identify potential risks in your portfolio with Ghostfolio

View File

@ -328,11 +328,7 @@
<gf-carousel [aria-label]="'Testimonials'">
<div *ngFor="let testimonial of testimonials" gf-carousel-item>
<div class="d-flex px-4">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"
[showLabel]="false"
></gf-logo>
<gf-logo class="mr-3 mt-2 pt-1" size="medium" [showLabel]="false" />
<div>
<div>{{ testimonial.quote }}</div>
<div class="mt-2 text-muted">
@ -361,10 +357,7 @@
</h2>
</div>
<div class="col-md-8 customer-map-container offset-md-2">
<gf-world-map-chart
format="👻"
[countries]="countriesOfSubscribersMap"
></gf-world-map-chart>
<gf-world-map-chart format="👻" [countries]="countriesOfSubscribersMap" />
</div>
</div>
@ -450,7 +443,7 @@
<div
class="align-items-center d-flex flex-column justify-content-center w-100"
>
<gf-logo size="medium"></gf-logo>
<gf-logo size="medium" />
<div>Wealth Management Software</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
<div class="container">
<div class="row">
<div class="col">
<gf-home-market></gf-home-market>
<gf-home-market />
</div>
</div>
</div>

View File

@ -15,7 +15,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@ -199,8 +199,14 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
}
public onExport(activityIds?: string[]) {
let fetchExportParams: any = { activityIds };
if (!activityIds) {
fetchExportParams = { filters: this.userService.getFilters() };
}
this.dataService
.fetchExport(activityIds)
.fetchExport(fetchExportParams)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
for (const activity of data.activities) {
@ -220,7 +226,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({

View File

@ -20,13 +20,13 @@
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(deleteAllActivities)="onDeleteAllActivities()"
(export)="onExport($event)"
(export)="onExport()"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
(importDividends)="onImportDividends()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"
></gf-activities-table-lazy>
/>
<gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
@ -44,7 +44,7 @@
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
(importDividends)="onImportDividends()"
></gf-activities-table>
/>
</div>
</div>

View File

@ -82,8 +82,7 @@
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
/><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select>
@ -357,7 +356,7 @@
[locale]="data.user?.settings?.locale"
[unit]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[value]="total"
></gf-value>
/>
<div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button

View File

@ -3,7 +3,7 @@
[deviceType]="data.deviceType"
[title]="dialogTitle"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
/>
<div class="flex-grow-1" mat-dialog-content>
<mat-stepper
@ -136,7 +136,7 @@
[sortDisabled]="true"
[totalItems]="totalItems"
(selectedActivities)="updateSelection($event)"
></gf-activities-table-lazy>
/>
<gf-activities-table
*ngIf="importStep === 1 && data?.user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
@ -153,7 +153,7 @@
[showFooter]="false"
[showSymbolColumn]="false"
(selectedActivities)="updateSelection($event)"
></gf-activities-table>
/>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
@ -214,4 +214,4 @@
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>
/>

View File

@ -148,9 +148,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initialize();
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
return this.fetchPortfolioDetails();
}),
takeUntil(this.unsubscribeSubject)
)
@ -159,7 +157,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData();
this.initializeAllocationsData();
this.isLoading = false;
@ -210,6 +208,26 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
if (this.user?.settings?.isExperimentalFeatures === true) {
this.isLoading = true;
this.initialize();
this.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails;
this.initializeAllocationsData();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
}
});
@ -217,7 +235,52 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initialize();
}
public initialize() {
public onAccountChartClicked({ symbol }: UniqueAsset) {
if (symbol && symbol !== UNKNOWN_KEY) {
this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true }
});
}
}
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private extractEtfProvider({
assetSubClass,
name
}: {
assetSubClass: PortfolioPosition['assetSubClass'];
name: string;
}) {
if (assetSubClass === 'ETF') {
const [firstWord] = name.split(' ');
return firstWord;
}
return UNKNOWN_KEY;
}
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters()
});
}
private initialize() {
this.accounts = {};
this.continents = {
[UNKNOWN_KEY]: {
@ -310,7 +373,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
};
}
public initializeAnalysisData() {
private initializeAllocationsData() {
for (const [
id,
{ name, valueInBaseCurrency, valueInPercentage }
@ -540,27 +603,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.markets[UNKNOWN_KEY].value / marketsTotal;
}
public onAccountChartClicked({ symbol }: UniqueAsset) {
if (symbol && symbol !== UNKNOWN_KEY) {
this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true }
});
}
}
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open(AccountDetailDialog, {
autoFocus: false,
@ -621,19 +663,4 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
});
});
}
private extractEtfProvider({
assetSubClass,
name
}: {
assetSubClass: PortfolioPosition['assetSubClass'];
name: string;
}) {
if (assetSubClass === 'ETF') {
const [firstWord] = name.split(' ');
return firstWord;
}
return UNKNOWN_KEY;
}
}

View File

@ -2,12 +2,14 @@
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Allocations</h1>
@if (!user?.settings?.isExperimentalFeatures) {
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
/>
}
</div>
</div>
<div class="row">
@ -22,7 +24,7 @@
size="medium"
[isPercent]="true"
[value]="isLoading ? undefined : portfolioDetails?.filteredValueInPercentage"
></gf-value>
/>
</mat-card-header>
<mat-card-content>
<mat-progress-bar
@ -50,7 +52,7 @@
[keys]="['id']"
[locale]="user?.settings?.locale"
[positions]="platforms"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -62,8 +64,8 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -73,7 +75,7 @@
[keys]="['currency']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -85,8 +87,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -96,7 +98,7 @@
[keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -119,7 +121,7 @@
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onSymbolChartClicked($event)"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -131,8 +133,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -143,7 +145,7 @@
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -155,8 +157,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -166,7 +168,7 @@
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="continents"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -178,8 +180,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -188,7 +190,7 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[locale]="user?.settings?.locale"
[positions]="marketsAdvanced"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -202,8 +204,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="world-map-chart-container">
@ -212,7 +214,7 @@
[format]="worldMapChartFormat"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[locale]="user?.settings?.locale"
></gf-world-map-chart>
/>
</div>
<div class="row">
<div class="col-xs-12 col-md my-2">
@ -275,7 +277,7 @@
[locale]="user?.settings?.locale"
[positions]="accounts"
(proportionChartClicked)="onAccountChartClicked($event)"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -287,8 +289,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -298,7 +300,7 @@
[keys]="['etfProvider']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -310,8 +312,8 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator
></mat-card-title>
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -322,7 +324,7 @@
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>

View File

@ -379,7 +379,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
positions,
positions.filter(({ netPerformancePercentage }) => {
return isNumber(netPerformancePercentage);
}),
'netPerformancePercentage'
).reverse();

View File

@ -7,14 +7,14 @@
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
/>
</div>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
/>
}
<div class="mb-5 row">
<div class="col-lg">
@ -30,7 +30,7 @@
[performanceDataItems]="performanceDataItemsInPercentage"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
></gf-benchmark-comparator>
/>
</div>
</div>
@ -51,7 +51,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="isLoadingInvestmentChart ? undefined : performance?.currentNetPerformance"
></gf-value>
/>
</div>
</div>
<div class="d-flex mb-3 ml-3 py-1">
@ -66,7 +66,7 @@
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="isLoadingInvestmentChart ? undefined : performance?.currentNetPerformancePercent"
></gf-value>
/>
</div>
</div>
<div class="d-flex py-1">
@ -81,7 +81,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="isLoadingInvestmentChart ? undefined : (performance?.currentNetPerformanceWithCurrencyEffect === null ? null : performance?.currentNetPerformanceWithCurrencyEffect - performance?.currentNetPerformance)"
></gf-value>
/>
</div>
</div>
<div class="d-flex ml-3 py-1">
@ -96,7 +96,7 @@
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="isLoadingInvestmentChart ? undefined : performance?.currentNetPerformancePercentWithCurrencyEffect - performance?.currentNetPerformancePercent"
></gf-value>
/>
</div>
</div>
<div><hr /></div>
@ -112,7 +112,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="isLoadingInvestmentChart ? undefined : performance?.currentNetPerformanceWithCurrencyEffect"
></gf-value>
/>
</div>
</div>
<div class="d-flex ml-3 py-1">
@ -127,7 +127,7 @@
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="isLoadingInvestmentChart ? undefined : performance?.currentNetPerformancePercentWithCurrencyEffect"
></gf-value>
/>
</div>
</div>
</mat-card-content>
@ -167,7 +167,7 @@
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
/>
</div>
</a>
</li>
@ -180,7 +180,7 @@
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>
</div>
</mat-card-content>
</mat-card>
@ -215,7 +215,7 @@
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="position.netPerformancePercentage"
></gf-value>
/>
</div>
</a>
</li>
@ -228,7 +228,7 @@
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>
</div>
</mat-card-content>
</mat-card>
@ -245,7 +245,7 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</div>
</div>
<div class="chart-container">
@ -260,7 +260,7 @@
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
></gf-investment-chart>
/>
</div>
</div>
</div>
@ -275,7 +275,7 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</div>
<gf-toggle
class="d-none d-lg-block"
@ -283,7 +283,7 @@
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
/>
</div>
<div *ngIf="streaks" class="row">
<div class="col-md-6 col-xs-12 my-2">
@ -317,7 +317,7 @@
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
[savingsRate]="savingsRate"
></gf-investment-chart>
/>
</div>
</div>
</div>
@ -332,7 +332,7 @@
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</div>
<gf-toggle
class="d-none d-lg-block"
@ -340,7 +340,7 @@
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
/>
</div>
<div class="chart-container">
<gf-investment-chart
@ -353,7 +353,7 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
></gf-investment-chart>
/>
</div>
</div>
</div>

View File

@ -8,7 +8,7 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<gf-fire-calculator
[annualInterestRate]="user?.settings?.annualInterestRate"
@ -25,7 +25,7 @@
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
(retirementDateChanged)="onRetirementDateChange($event)"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
/>
</div>
</div>
</div>
@ -35,7 +35,7 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
@ -45,14 +45,14 @@
height: '1rem',
width: '100%'
}"
></ngx-skeleton-loader>
/>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
/>
</div>
<div *ngIf="!isLoading" i18n>
If you retire today, you would be able to withdraw
@ -63,7 +63,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear?.toNumber()"
></gf-value>
/>
per year</span
>
or
@ -74,7 +74,7 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value>
/>
per month</span
>, based on your total assets of
<span class="font-weight-bold"
@ -84,8 +84,8 @@
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="fireWealth?.toNumber()"
></gf-value
></span>
/>
</span>
and a withdrawal rate of 4%.
</div>
</div>
@ -112,12 +112,12 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="emergencyFundRules"
></gf-rules>
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
@ -125,12 +125,12 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="currencyClusterRiskRules"
></gf-rules>
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
@ -138,12 +138,12 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="accountClusterRiskRules"
></gf-rules>
/>
</div>
<div>
<h4 class="align-items-center d-flex m-0">
@ -151,12 +151,12 @@
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
/>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules"
></gf-rules>
/>
</div>
</div>
</div>

View File

@ -86,16 +86,14 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
? $localize`Filter by account or tag...`
: '';
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
return this.fetchPortfolioDetails();
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData();
this.initialize();
this.isLoading = false;
@ -146,17 +144,41 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
...tagFilters
];
if (this.user?.settings?.isExperimentalFeatures === true) {
this.holdings = undefined;
this.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
}
});
}
public initialize() {
this.holdings = [];
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public initializeAnalysisData() {
this.initialize();
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters()
});
}
private initialize() {
this.holdings = [];
for (const [symbol, holding] of Object.entries(
this.portfolioDetails.holdings
@ -165,11 +187,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openPositionDialog({
dataSource,
symbol

View File

@ -2,12 +2,14 @@
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
@if (!user?.settings?.isExperimentalFeatures) {
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
/>
}
</div>
</div>
<div class="row">
@ -18,7 +20,7 @@
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
></gf-holdings-table>
/>
<div
*ngIf="hasPermissionToCreateOrder && holdings?.length > 0"
class="text-center"

View File

@ -171,10 +171,7 @@
<div class="align-items-center d-flex mb-2">
<h4 class="align-items-center d-flex flex-grow-1 m-0">
<span>Premium</span>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
<gf-premium-indicator class="ml-1" [enableLink]="false" />
</h4>
<div *ngIf="user?.subscription?.type === 'Premium'">
<ion-icon class="mr-1" name="checkmark-outline" />

View File

@ -20,7 +20,7 @@
[keys]="['symbol']"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -35,7 +35,7 @@
[keys]="['currency']"
[maxItems]="10"
[positions]="positions"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -50,7 +50,7 @@
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -64,7 +64,7 @@
[isInPercent]="true"
[keys]="['name']"
[positions]="continents"
></gf-portfolio-proportion-chart>
/>
</mat-card-content>
</mat-card>
</div>
@ -81,7 +81,7 @@
format="{0}%"
[countries]="countries"
[isInPercent]="true"
></gf-world-map-chart>
/>
</div>
<div class="row">
<div class="col-xs-12 col-md my-2">
@ -135,7 +135,7 @@
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
></gf-holdings-table>
/>
</div>
</div>
<div class="row my-5">

View File

@ -9,7 +9,7 @@
<div
class="align-items-center d-flex flex-column justify-content-center w-100"
>
<gf-logo size="large"></gf-logo>
<gf-logo size="large" />
<p class="lead m-0">Wealth Management Software</p>
</div>
</div>

View File

@ -44,6 +44,7 @@ import { SumioPageComponent } from './products/sumio-page.component';
import { TillerPageComponent } from './products/tiller-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component';
import { VyzerPageComponent } from './products/vyzer-page.component';
import { WealthfolioPageComponent } from './products/wealthfolio-page.component';
import { WealthicaPageComponent } from './products/wealthica-page.component';
import { WhalPageComponent } from './products/whal-page.component';
import { YeekateePageComponent } from './products/yeekatee-page.component';
@ -528,6 +529,14 @@ export const products: Product[] = [
pricingPerYear: '$348',
slogan: 'Virtual Family Office for Smart Wealth Management'
},
{
component: WealthfolioPageComponent,
hasSelfHostingAbility: true,
key: 'wealthfolio',
languages: ['English'],
name: 'Wealthfolio',
slogan: 'Desktop Investment Tracker'
},
{
component: WealthicaPageComponent,
founded: 2015,

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-wealthfolio-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class WealthfolioPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'wealthfolio';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

View File

@ -4,7 +4,7 @@
<div
class="align-items-center d-flex flex-column justify-content-center mb-4 w-100"
>
<gf-logo size="medium"></gf-logo>
<gf-logo size="medium" />
</div>
<div *ngIf="!hasError" class="col d-flex justify-content-center">

View File

@ -279,8 +279,14 @@ export class DataService {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
}
public fetchExport(activityIds?: string[]) {
let params = new HttpParams();
public fetchExport({
activityIds,
filters
}: {
activityIds?: string[];
filters?: Filter[];
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (activityIds) {
params = params.append('activityIds', activityIds.join(','));

View File

@ -47,18 +47,26 @@ export class UserService extends ObservableStore<UserStoreState> {
}
public getFilters() {
const filters: Filter[] = [];
const user = this.getState().user;
return user?.settings?.isExperimentalFeatures === true
? user.settings['filters.tags']
? <Filter[]>[
{
id: user.settings['filters.tags'][0],
type: 'TAG'
}
]
: []
: [];
if (user?.settings?.isExperimentalFeatures === true) {
if (user.settings['filters.accounts']) {
filters.push({
id: user.settings['filters.accounts'][0],
type: 'ACCOUNT'
});
}
if (user.settings['filters.tags']) {
filters.push({
id: user.settings['filters.tags'][0],
type: 'TAG'
});
}
}
return filters;
}
public remove() {

View File

@ -1,6 +1,16 @@
{
"createdAt": "2023-12-30T00:00:00.000Z",
"createdAt": "2024-01-29T00:00:00.000Z",
"data": [
{
"name": "Aptabase",
"description": "Analytics for Apps, open source, simple and privacy-friendly. SDKs for Swift, React Native, Electron, Flutter and many others.",
"href": "https://aptabase.com"
},
{
"name": "Argos",
"description": "Argos provides the developer tools to debug tests and detect visual regressions..",
"href": "https://argos-ci.com"
},
{
"name": "BoxyHQ",
"description": "BoxyHQs suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
@ -81,6 +91,16 @@
"description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
"href": "https://infisical.com"
},
{
"name": "Langfuse",
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.",
"href": "https://langfuse.com"
},
{
"name": "Lost Pixel",
"description": "Open source visual regression testing alternative to Percy & Chromatic",
"href": "https://lost-pixel.com"
},
{
"name": "Mockoon",
"description": "Mockoon is the easiest and quickest way to design and run mock REST APIs.",

View File

@ -48,7 +48,7 @@
<link href="../assets/site.webmanifest" rel="manifest" />
</head>
<body>
<gf-root></gf-root>
<gf-root />
<script src="../ionicons/ionicons.esm.js" type="module"></script>
<script nomodule="" src="ionicons.js"></script>

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