Compare commits

..

27 Commits

Author SHA1 Message Date
00a2b60eb5 Release 2.49.0 (#2979) 2024-02-09 19:30:00 +01:00
fcbf2f1645 Feature/remove lazy from name of activities table (#2978)
* Remove lazy from name

* Update translations
2024-02-09 18:48:05 +01:00
460266a501 Feature/upgrade yahoo finance2 to version 2.9.1 (#2963)
* Upgrade yahoo-finance2 to version 2.9.1

* Update changelog
2024-02-09 18:41:18 +01:00
9fe90273c7 Feature/move assistant to general availability (#2977)
* Move assistant from experimental to general availability

* Update changelog
2024-02-09 18:25:15 +01:00
4078229fe6 Feature/add button to apply filters in assistant (#2971)
* Add apply filters button

* Update changelog
2024-02-09 09:45:54 +01:00
609c03f174 Add analytics image (#2970) 2024-02-08 08:00:04 +01:00
e7d4641d13 Feature/reload data on logo click (#2959)
* Reload data on logo click

* Update changelog
2024-02-07 21:03:28 +01:00
cc1d9811e0 Release 2.48.1 (#2968) 2024-02-06 17:00:40 +01:00
35450ac004 Bugfix/add missing data provider info to search results of coingecko (#2967)
* Add missing data provider info

* Update changelog
2024-02-06 16:58:54 +01:00
9c18f48a32 Release 2.48.0 (#2962) 2024-02-05 19:57:40 +01:00
87529490c3 Feature/refresh cryptocurrencies list 20240205 (#2961)
* Update cryptocurrencies.json

* Update changelog
2024-02-05 19:56:16 +01:00
893e76f83f Feature/provide data provider info in search (#2958)
* Provide data provider info in search

* Update changelog
2024-02-05 19:55:39 +01:00
06ba7a4b1b Feature/extend assistant by asset class selector (#2957)
* Remove tabs

* Add asset class selector

* Update changelog
2024-02-04 15:50:58 +01:00
c68d113d27 Feature/improve usability of account and tag selector of assistant (#2955)
* Change radio button to select

* account
* tag

* Update changelog
2024-02-04 12:11:01 +01:00
69e3bee52c Feature/upgrade prettier to version 3.2.5 (#2954)
* Upgrade prettier to version 3.2.5

* Update changelog
2024-02-04 12:10:33 +01:00
cea569c987 Add Fina (#2956) 2024-02-04 12:10:22 +01:00
Hey
2a38a16f6b Feature/Improve error logs for timeout in data provider services (#2953)
* Improve error logs for timeout in data provider services

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-02-04 11:56:00 +01:00
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
97 changed files with 6507 additions and 6796 deletions

View File

@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.49.0 - 2024-02-09
### Added
- Added a button to apply the active filters in the assistant
### Changed
- Moved the assistant from experimental to general availability
- Improved the usability by reloading the content with a logo click on the home page
- Upgraded `yahoo-finance2` from version `2.9.0` to `2.9.1`
## 2.48.1 - 2024-02-06
### Fixed
- Added the missing data provider information to the _CoinGecko_ service
## 2.48.0 - 2024-02-05
### Added
- Extended the assistant by an asset class selector (experimental)
- Added the data provider information to the search endpoint
### Changed
- Improved the usability of the account selector in the assistant (experimental)
- Improved the usability of the tag selector in the assistant (experimental)
- Improved the error logs for a timeout in the data provider services
- Refreshed the cryptocurrencies list
- Upgraded `prettier` from version `3.2.4` to `3.2.5`
## 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

View File

@ -280,6 +280,10 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Analytics
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)

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

@ -64,16 +64,13 @@ export class ImportController {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const userCurrency = this.request.user.Settings.settings.baseCurrency;
try {
const activities = await this.importService.import({
isDryRun,
maxActivitiesToImport,
userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities,
userId: this.request.user.id
user: this.request.user
});
return { activities };

View File

@ -21,7 +21,8 @@ import {
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount
OrderWithAccount,
UserWithSettings
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
@ -138,17 +139,16 @@ export class ImportService {
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
userCurrency,
userId
user
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number;
userCurrency: string;
userId: string;
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([
@ -171,7 +171,7 @@ export class ImportService {
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
let oldAccountId: string;
const platformId = account.platformId;
@ -184,7 +184,7 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: userId } }
User: { connect: { id: user.id } }
};
if (
@ -200,7 +200,7 @@ export class ImportService {
const newAccount = await this.accountService.createAccount(
accountObject,
userId
user.id
);
// Store the new to old account ID mappings for updating activities
@ -231,16 +231,17 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport
maxActivitiesToImport,
user
});
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,
userCurrency,
userId
userId: user.id
});
const accounts = (await this.accountService.getAccounts(userId)).map(
const accounts = (await this.accountService.getAccounts(user.id)).map(
({ id, name }) => {
return { id, name };
}
@ -345,7 +346,6 @@ export class ImportService {
quantity,
type,
unitPrice,
userId,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
@ -374,7 +374,8 @@ export class ImportService {
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
updatedAt: new Date(),
userId: user.id
};
} else {
if (error) {
@ -388,7 +389,6 @@ export class ImportService {
quantity,
type,
unitPrice,
userId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
@ -406,7 +406,8 @@ export class ImportService {
}
},
updateAccountBalance: false,
User: { connect: { id: userId } }
User: { connect: { id: user.id } },
userId: user.id
});
}
@ -553,10 +554,12 @@ export class ImportService {
private async validateActivities({
activitiesDto,
maxActivitiesToImport
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
@ -583,6 +586,21 @@ export class ImportService {
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.dataProviderService.getDataProvider(
DataSource[dataSource]
);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
}
const assetProfile = {
currency,
...(

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

@ -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

@ -1,9 +1,11 @@
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface LookupItem {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
currency: string;
dataProviderInfo: DataProviderInfo;
dataSource: DataSource;
name: string;
symbol: string;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -114,6 +114,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -456,6 +460,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -818,6 +826,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1026,6 +1038,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -12,6 +12,7 @@ import {
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
@ -44,6 +45,12 @@ export class AlphaVantageService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
@ -118,6 +125,7 @@ export class AlphaVantageService implements DataProviderInterface {
assetClass: undefined,
assetSubClass: undefined,
currency: bestMatch['8. currency'],
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']

View File

@ -80,7 +80,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -91,6 +91,14 @@ export class CoinGeckoService implements DataProviderInterface {
return response;
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false,
name: 'CoinGecko',
url: 'https://coingecko.com'
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
@ -195,7 +203,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -235,6 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName()
};
});
@ -242,7 +251,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -252,11 +261,4 @@ export class CoinGeckoService implements DataProviderInterface {
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'CoinGecko',
url: 'https://coingecko.com'
};
}
}

View File

@ -107,6 +107,31 @@ export class DataProviderService {
return response;
}
public getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
(currentDataProviderInterface) => {
return (
currentDataProviderInterface.getName() ===
this.dataProviderMapping[dataProviderInterface.getName()]
);
}
);
if (mappedDataProviderInterface) {
return mappedDataProviderInterface;
}
}
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
}
throw new Error('No data provider has been found.');
}
public getDataSourceForExchangeRates(): DataSource {
return DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
@ -520,20 +545,15 @@ export class DataProviderService {
return { items: lookupItems };
}
let dataSources = this.configurationService.get('DATA_SOURCES');
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
dataSources = dataSources.filter((dataSource) => {
return !this.isPremiumDataSource(DataSource[dataSource]);
let dataProviderServices = this.configurationService
.get('DATA_SOURCES')
.map((dataSource) => {
return this.getDataProvider(DataSource[dataSource]);
});
}
for (const dataSource of dataSources) {
for (const dataProviderService of dataProviderServices) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search({
dataProviderService.search({
includeIndices,
query
})
@ -555,6 +575,16 @@ export class DataProviderService {
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => {
if (
!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') ||
user.subscription.type === 'Premium'
) {
lookupItem.dataProviderInfo.isPremium = false;
}
return lookupItem;
});
return {
@ -562,31 +592,6 @@ export class DataProviderService {
};
}
private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
(currentDataProviderInterface) => {
return (
currentDataProviderInterface.getName() ===
this.dataProviderMapping[dataProviderInterface.getName()]
);
}
);
if (mappedDataProviderInterface) {
return mappedDataProviderInterface;
}
}
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
}
throw new Error('No data provider has been found.');
}
private hasCurrency({
currency,
dataGatheringItems
@ -602,14 +607,6 @@ export class DataProviderService {
});
}
private isPremiumDataSource(aDataSource: DataSource) {
const premiumDataSources: DataSource[] = [
DataSource.EOD_HISTORICAL_DATA,
DataSource.FINANCIAL_MODELING_PREP
];
return premiumDataSources.includes(aDataSource);
}
private transformHistoricalData({
allData,
currency,

View File

@ -16,6 +16,7 @@ import {
REPLACE_NAME_PARTS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
@ -58,6 +59,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: true
};
}
public async getDividends({
from,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
@ -271,7 +278,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -312,7 +319,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
dataSource,
name,
symbol,
currency: this.convertCurrency(currency)
currency: this.convertCurrency(currency),
dataProviderInfo: this.getDataProviderInfo()
};
}
)
@ -423,7 +431,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}

View File

@ -45,6 +45,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: true,
name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs'
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
@ -143,7 +151,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -192,7 +200,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
@ -202,11 +210,4 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs'
};
}
}

View File

@ -14,6 +14,7 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
@ -40,6 +41,12 @@ export class GoogleSheetsService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
@ -177,7 +184,11 @@ export class GoogleSheetsService implements DataProviderInterface {
}
});
return { items };
return {
items: items.map((item) => {
return { ...item, dataProviderInfo: this.getDataProviderInfo() };
})
};
}
private async getSheet({

View File

@ -3,6 +3,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -11,6 +12,8 @@ export interface DataProviderInterface {
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo;
getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse;
}>;

View File

@ -18,7 +18,10 @@ import {
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as cheerio from 'cheerio';
@ -59,6 +62,12 @@ export class ManualService implements DataProviderInterface {
return assetProfile;
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
@ -214,7 +223,11 @@ export class ManualService implements DataProviderInterface {
return !isUUID(symbol);
});
return { items };
return {
items: items.map((item) => {
return { ...item, dataProviderInfo: this.getDataProviderInfo() };
})
};
}
public async test(scraperConfiguration: ScraperConfiguration) {

View File

@ -13,6 +13,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
@ -37,6 +38,12 @@ export class RapidApiService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}

View File

@ -14,6 +14,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
@ -47,6 +48,12 @@ export class YahooFinanceService implements DataProviderInterface {
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
};
}
public async getDividends({
from,
granularity = 'day',
@ -283,6 +290,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass,
symbol,
currency: marketDataItem.currency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname,

View File

@ -10,7 +10,6 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import {
@ -43,7 +42,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public equity: number;
public hasImpersonationId: boolean;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[];
public holdings: PortfolioPosition[];
@ -65,7 +63,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {
this.userService.stateChanged
@ -136,13 +133,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioPerformance();
@ -165,20 +155,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public onExport() {
let activityIds = [];
if (this.user?.settings?.isExperimentalFeatures === true) {
activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
} else {
activityIds = this.activities.map(({ id }) => {
return id;
});
}
let activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
this.dataService
.fetchExport(activityIds)
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
@ -215,36 +197,21 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
private fetchActivities() {
this.isLoadingActivities = true;
if (this.user?.settings?.isExperimentalFeatures === true) {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.isLoadingActivities = false;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
} else {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
});
}
private fetchPortfolioPerformance() {
@ -268,7 +235,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
this.data.hasImpersonationId ||
this.user.settings.isRestrictedView
? netWorthInPercentage
: netWorth
};

View File

@ -25,7 +25,7 @@
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isInPercent]="data.hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
/>
@ -86,13 +86,12 @@
<ion-icon name="swap-vertical-outline" />
<div class="d-none d-sm-block ml-2" i18n>Activities</div>
</ng-template>
<gf-activities-table-lazy
*ngIf="user?.settings?.isExperimentalFeatures === true"
<gf-activities-table
[baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
@ -103,19 +102,6 @@
(export)="onExport()"
(sortChanged)="onSortChanged($event)"
/>
<gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
(export)="onExport()"
/>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
@ -126,7 +112,7 @@
[accountBalances]="accountBalances"
[accountId]="data.accountId"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
[showActions]="!data.hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
/>
</mat-tab>

View File

@ -8,7 +8,6 @@ import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-heade
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -21,7 +20,6 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfHoldingsTableModule,

View File

@ -6,11 +6,12 @@
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
(click)="onLogoClick()"
>
<gf-logo class="px-2" [label]="pageTitle" />
</a>
</div>
<span class="spacer"></span>
<span class="gf-spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
@ -119,11 +120,7 @@
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>
@if (user?.settings?.isExperimentalFeatures) {
<ion-icon class="rotate-90" name="options-outline" />
} @else {
<ion-icon name="search-outline" />
}
<ion-icon class="rotate-90" name="options-outline" />
</button>
<mat-menu
#assistantMenu="matMenu"
@ -324,7 +321,7 @@
/>
</a>
</div>
<span class="spacer"></span>
<span class="gf-spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a

View File

@ -38,10 +38,6 @@
}
}
}
.spacer {
flex: 1 1 auto;
}
}
}

View File

@ -13,6 +13,7 @@ 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 { LayoutService } from '@ghostfolio/client/core/layout.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -89,6 +90,7 @@ export class HeaderComponent implements OnChanges {
private dataService: DataService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService,
@ -170,6 +172,8 @@ export class HeaderComponent implements OnChanges {
if (filter.type === 'ACCOUNT') {
filtersType = 'accounts';
} else if (filter.type === 'ASSET_CLASS') {
filtersType = 'assetClasses';
} else if (filter.type === 'TAG') {
filtersType = 'tags';
}
@ -190,6 +194,12 @@ export class HeaderComponent implements OnChanges {
});
}
public onLogoClick() {
if (this.currentRoute === 'home' || this.currentRoute === 'zen') {
this.layoutService.getShouldReloadSubject().next();
}
}
public onMenuClosed() {
this.isMenuOpen = false;
}

View File

@ -1,12 +1,4 @@
<div class="container justify-content-center p-3">
<div *ngIf="!user?.settings?.isExperimentalFeatures" class="mb-3 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
/>
</div>
<div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card appearance="outlined">

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
LineChartItem,
@ -43,6 +44,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService,
private userService: UserService
) {
this.userService.stateChanged
@ -73,6 +75,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.layoutService.shouldReloadContent$
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.update();
});
this.showDetails =
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';

View File

@ -96,17 +96,6 @@
[showDetails]="showDetails"
[unit]="unit"
/>
<div
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
class="text-center"
>
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
/>
</div>
</div>
</div>
</ng-template>

View File

@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
@ -16,7 +15,6 @@ import { HomeOverviewComponent } from './home-overview.component';
GfLineChartModule,
GfNoTransactionsInfoModule,
GfPortfolioPerformanceModule,
GfToggleModule,
MatButtonModule,
RouterModule
],

View File

@ -268,20 +268,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}
public onExport() {
let activityIds = [];
if (this.user?.settings?.isExperimentalFeatures === true) {
activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
} else {
activityIds = this.activities.map(({ id }) => {
return id;
});
}
let activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
this.dataService
.fetchExport(activityIds)
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({

View File

@ -249,13 +249,12 @@
<div class="row" [ngClass]="{ 'd-none': !activities?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table-lazy
*ngIf="user?.settings?.isExperimentalFeatures === true"
<gf-activities-table
[baseCurrency]="data.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
@ -267,20 +266,6 @@
[totalItems]="totalItems"
(export)="onExport()"
/>
<gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showNameColumn]="false"
(export)="onExport()"
/>
</div>
</div>

View File

@ -5,7 +5,6 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
@ -20,7 +19,6 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
imports: [
CommonModule,
GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDataProviderCreditsModule,
GfDialogFooterModule,
GfDialogHeaderModule,

View File

@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LayoutService {
public shouldReloadContent$: Observable<void>;
private shouldReloadSubject = new Subject<void>();
public constructor() {
this.shouldReloadContent$ = this.shouldReloadSubject.asObservable();
}
public getShouldReloadSubject() {
return this.shouldReloadSubject;
}
}

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">

View File

@ -121,43 +121,25 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
}
public fetchActivities() {
if (this.user?.settings?.isExperimentalFeatures === true) {
this.dataService
.fetchActivities({
filters: this.userService.getFilters(),
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.dataService
.fetchActivities({
filters: this.userService.getFilters(),
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
} else {
this.dataService
.fetchActivities({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
if (
this.hasPermissionToCreateActivity &&
this.activities?.length <= 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
});
}
public onChangePage(page: PageEvent) {
@ -199,8 +181,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 +208,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

@ -2,8 +2,7 @@
<div class="mb-3 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-activities-table-lazy
*ngIf="user?.settings?.isExperimentalFeatures === true"
<gf-activities-table
[baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="deviceType"
@ -20,31 +19,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
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(deleteAllActivities)="onDeleteAllActivities()"
(export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
(importDividends)="onImportDividends()"
/>
</div>
</div>

View File

@ -4,7 +4,6 @@ import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
@ -18,7 +17,6 @@ import { GfImportActivitiesDialogModule } from './import-activities-dialog/impor
ActivitiesPageRoutingModule,
CommonModule,
GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfCreateOrUpdateActivityDialogModule,
GfImportActivitiesDialogModule,
MatButtonModule,

View File

@ -116,8 +116,8 @@
</ng-template>
<div class="pt-3">
<ng-container *ngIf="errorMessages?.length === 0; else errorMessage">
<gf-activities-table-lazy
*ngIf="importStep === 1 && data?.user?.settings?.isExperimentalFeatures === true"
<gf-activities-table
*ngIf="importStep === 1"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data?.deviceType"
@ -137,23 +137,6 @@
[totalItems]="totalItems"
(selectedActivities)="updateSelection($event)"
/>
<gf-activities-table
*ngIf="importStep === 1 && data?.user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[pageSize]="maxSafeInteger"
[showActions]="false"
[showCheckbox]="true"
[showFooter]="false"
[showSymbolColumn]="false"
(selectedActivities)="updateSelection($event)"
/>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>

View File

@ -13,7 +13,6 @@ import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-heade
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { ImportActivitiesDialog } from './import-activities-dialog.component';
@ -23,7 +22,6 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
CommonModule,
FormsModule,
GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfFileDropModule,

View File

@ -11,7 +11,6 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
Filter,
PortfolioDetails,
PortfolioPosition,
UniqueAsset,
@ -24,7 +23,7 @@ import { Account, AssetClass, DataSource, Platform } from '@prisma/client';
import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-allocations-page',
@ -38,8 +37,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public continents: {
[code: string]: { name: string; value: number };
};
@ -47,7 +44,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean;
public isLoading = false;
public markets: {
@ -60,7 +56,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public placeholder = '';
public platforms: {
[id: string]: Pick<Platform, 'name'> & {
id: string;
@ -135,98 +130,34 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId;
});
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0
? $localize`Filter by account or tag...`
: '';
this.initialize();
return this.fetchPortfolioDetails();
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails;
this.initializeAllocationsData();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
const accountFilters: Filter[] = this.user.accounts.map(
({ id, name }) => {
return {
id,
label: name,
type: 'ACCOUNT'
};
}
);
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
});
this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.worldMapChartFormat =
this.hasImpersonationId || this.user.settings.isRestrictedView
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
if (this.user?.settings?.isExperimentalFeatures === true) {
this.isLoading = true;
this.isLoading = true;
this.initialize();
this.initialize();
this.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => {
this.initialize();
this.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails;
this.portfolioDetails = portfolioDetails;
this.initializeAllocationsData();
this.initializeAllocationsData();
this.isLoading = false;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
@ -273,10 +204,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters()
filters: this.userService.getFilters()
});
}

View File

@ -2,14 +2,6 @@
<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)"
/>
}
</div>
</div>
<div class="row">

View File

@ -4,7 +4,6 @@ import { MatCardModule } from '@angular/material/card';
import { MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -17,7 +16,6 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [
AllocationsPageRoutingModule,
CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule,
GfPremiumIndicatorModule,
GfWorldMapChartModule,

View File

@ -8,7 +8,6 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
Filter,
HistoricalDataItem,
PortfolioInvestments,
PortfolioPerformance,
@ -17,14 +16,14 @@ import {
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-analysis-page',
@ -32,8 +31,6 @@ import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[];
@ -42,7 +39,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public dividendsByGroup: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
public filters$ = new Subject<Filter[]>();
public firstOrderDate: Date;
public hasImpersonationId: boolean;
public investments: InvestmentItem[];
@ -58,7 +54,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public placeholder = '';
public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks'];
public top3: Position[];
@ -118,61 +113,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId;
});
this.filters$
.pipe(
distinctUntilChanged(),
map((filters) => {
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0
? $localize`Filter by account or tag...`
: '';
this.update();
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
const accountFilters: Filter[] = this.user.accounts.map(
({ id, name }) => {
return {
id,
label: name,
type: 'ACCOUNT'
};
}
);
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
});
this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.update();
}
});
@ -196,24 +142,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
});
}
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
this.fetchDividendsAndInvestments();
@ -227,10 +155,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private fetchDividendsAndInvestments() {
this.dataService
.fetchDividends({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
})
@ -243,10 +168,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchInvestments({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
})
@ -321,10 +243,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchPortfolioPerformance({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
@ -370,10 +289,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchPositions({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,21 +1,5 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
@if (!user?.settings?.isExperimentalFeatures) {
<div class="my-4 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
/>
</div>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
/>
}
<div class="mb-5 row">
<div class="col-lg">
<gf-benchmark-comparator

View File

@ -7,17 +7,15 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
Filter,
PortfolioDetails,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, DataSource } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-holdings-page',
@ -25,15 +23,11 @@ import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
templateUrl: './holdings-page.html'
})
export class HoldingsPageComponent implements OnDestroy, OnInit {
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public isLoading = false;
public placeholder = '';
public portfolioDetails: PortfolioDetails;
public user: User;
@ -75,31 +69,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId;
});
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0
? $localize`Filter by account or tag...`
: '';
return this.fetchPortfolioDetails();
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initialize();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -111,52 +80,17 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
permissions.createOrder
);
const accountFilters: Filter[] = this.user.accounts.map(
({ id, name }) => {
return {
id,
label: name,
type: 'ACCOUNT'
};
}
);
this.holdings = undefined;
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
this.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
});
this.allFilters = [
...accountFilters,
...assetClassFilters,
...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();
}
@ -170,10 +104,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters()
filters: this.userService.getFilters()
});
}

View File

@ -2,14 +2,6 @@
<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)"
/>
}
</div>
</div>
<div class="row">

View File

@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
import { HoldingsPageRoutingModule } from './holdings-page-routing.module';
@ -11,7 +10,6 @@ import { HoldingsPageComponent } from './holdings-page.component';
declarations: [HoldingsPageComponent],
imports: [
CommonModule,
GfActivitiesFilterModule,
GfHoldingsTableModule,
HoldingsPageRoutingModule,
MatButtonModule

View File

@ -15,6 +15,7 @@ import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
import { EightFiguresPageComponent } from './products/eightfigures-page.component';
import { EmpowerPageComponent } from './products/empower-page.component';
import { ExirioPageComponent } from './products/exirio-page.component';
import { FinaPageComponent } from './products/fina-page.component';
import { FinaryPageComponent } from './products/finary-page.component';
import { FinWisePageComponent } from './products/finwise-page.component';
import { FolisharePageComponent } from './products/folishare-page.component';
@ -220,6 +221,18 @@ export const products: Product[] = [
pricingPerYear: '$100',
slogan: 'All your wealth, in one place.'
},
{
component: FinaPageComponent,
founded: 2023,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'fina',
languages: ['English'],
name: 'Fina',
origin: $localize`United States`,
pricingPerYear: '$115',
slogan: 'Flexible Financial Management'
},
{
component: FinaryPageComponent,
founded: 2020,

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-fina-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class FinaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'fina';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

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

@ -50,20 +50,25 @@ export class UserService extends ObservableStore<UserStoreState> {
const filters: Filter[] = [];
const user = this.getState().user;
if (user?.settings?.isExperimentalFeatures === true) {
if (user.settings['filters.accounts']) {
filters.push({
id: user.settings['filters.accounts'][0],
type: 'ACCOUNT'
});
}
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'
});
}
if (user.settings['filters.assetClasses']) {
filters.push({
id: user.settings['filters.assetClasses'][0],
type: 'ASSET_CLASS'
});
}
if (user.settings['filters.tags']) {
filters.push({
id: user.settings['filters.tags'][0],
type: 'TAG'
});
}
return filters;

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.",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -379,6 +379,10 @@ ngx-skeleton-loader {
cursor: pointer;
}
.gf-spacer {
flex: 1 1 auto;
}
.gf-table {
@include gf-table;
}

0
git-hooks/pre-commit Executable file → Normal file
View File

View File

@ -1,4 +1,5 @@
export interface DataProviderInfo {
name: string;
url: string;
isPremium: boolean;
name?: string;
url?: string;
}

View File

@ -2,6 +2,7 @@ export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
investmentValueWithCurrencyEffect?: number;
marketPrice?: number;
netPerformance?: number;
netPerformanceInPercentage?: number;

View File

@ -19,6 +19,7 @@ import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface';
import type { HistoricalDataItem } from './historical-data-item.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface';
import type { PortfolioChart } from './portfolio-chart.interface';
import type { PortfolioDetails } from './portfolio-details.interface';
@ -74,6 +75,7 @@ export {
HistoricalDataItem,
ImportResponse,
InfoItem,
InvestmentItem,
LineChartItem,
OAuthResponse,
PortfolioChart,

View File

@ -14,7 +14,10 @@ export interface SymbolMetrics {
hasErrors: boolean;
initialValue: Big;
initialValueWithCurrencyEffect: Big;
investmentValues: {
investmentValuesAccumulated: {
[date: string]: Big;
};
investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
};
investmentValuesWithCurrencyEffect: {

View File

@ -1,490 +0,0 @@
<div *ngIf="hasPermissionToCreateActivity" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline" />
<ng-container i18n>Import Activities</ng-container>...
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="mx-1 no-min-width px-2"
mat-stroked-button
[matMenuTriggerFor]="activitiesMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<button
mat-menu-item
[disabled]="dataSource?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline" />
<ng-container i18n>Import Dividends</ng-container>...
</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource?.data.length === 0"
(click)="onExport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" />
<span i18n>Export Activities</span>
</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline" />
<span i18n>Export Drafts as ICS</span>
</span>
</button>
<button
class="align-items-center d-flex"
mat-menu-item
(click)="onDeleteAllActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete all Activities</span>
</span>
</button>
</mat-menu>
</div>
<div class="activities">
<table
class="gf-table w-100"
mat-table
matSort
[dataSource]="dataSource"
[matSortActive]="sortColumn"
[matSortDirection]="sortDirection"
[matSortDisabled]="sortDisabled"
>
<ng-container matColumnDef="select" sticky>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<mat-checkbox
color="primary"
[checked]="
areAllRowsSelected() && !hasErrors && selectedRows.hasValue()
"
[disabled]="hasErrors"
[indeterminate]="selectedRows.hasValue() && !areAllRowsSelected()"
(change)="$event ? toggleAllRows() : null"
></mat-checkbox>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<mat-checkbox
color="primary"
[checked]="element.error ? false : selectedRows.isSelected(element)"
[disabled]="element.error"
(change)="$event ? selectedRows.toggle(element) : null"
(click)="$event.stopPropagation()"
></mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="importStatus">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n></ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div
*ngIf="element.error"
class="d-flex"
matTooltipPosition="above"
[matTooltip]="element.error.message"
>
<ion-icon class="text-danger" name="alert-circle-outline" />
</div>
</td>
</ng-container>
<ng-container matColumnDef="icon">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-symbol-icon
[dataSource]="element.SymbolProfile?.dataSource"
[symbol]="element.SymbolProfile?.symbol"
[tooltip]="element.SymbolProfile?.name"
/>
<div>{{ element.dataSource }}</div>
</td>
</ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="d-flex align-items-center">
<div>
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
<span
*ngIf="element.isDraft"
class="badge badge-secondary ml-1"
i18n
>Draft</span
>
</div>
</div>
<div *ngIf="!isUUID(element.SymbolProfile?.symbol)">
<small class="text-muted">{{
element.SymbolProfile?.symbol | gfSymbol
}}</small>
</div>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-activity-type [activityType]="element.type" />
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Date</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Quantity</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.quantity"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="unitPrice">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Unit Price</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.unitPrice"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="fee">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Fee</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.fee"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
>
<ng-container i18n>Value</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.value"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
<ng-container i18n>Currency</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.SymbolProfile?.currency }}
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none justify-content-end px-1"
mat-header-cell
>
<ng-container i18n>Value</ng-container>
</th>
<td *matCellDef="let element" class="d-lg-none d-xl-none px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.valueInBaseCurrency"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Account?.Platform?.url"
class="mr-1"
[tooltip]="element.Account?.Platform?.name"
[url]="element.Account?.Platform?.url"
/>
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="comment">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<button
*ngIf="element.comment"
class="mx-1 no-min-width px-2"
mat-button
title="Note"
(click)="onOpenComment(element.comment); $event.stopPropagation()"
>
<ion-icon name="document-text-outline" />
</button>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
*ngIf="
!hasPermissionToCreateActivity && hasPermissionToExportActivities
"
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="activitiesMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<button
*ngIf="hasPermissionToCreateActivity"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-upload-outline" />
<ng-container i18n>Import Activities</ng-container>...
</span>
</button>
<button
*ngIf="hasPermissionToCreateActivity"
mat-menu-item
[disabled]="dataSource?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline" />
<ng-container i18n>Import Dividends</ng-container>...
</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource?.data.length === 0"
(click)="onExport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" />
<span i18n>Export Activities</span>
</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline" />
<span i18n>Export Drafts as ICS</span>
</span>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
*ngIf="showActions"
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="activityMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #activityMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
<button mat-menu-item (click)="onCloneActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Clone</span>
</span>
</button>
<button
mat-menu-item
[disabled]="!element.isDraft"
(click)="onExportDraft(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline" />
<span i18n>Export Draft as ICS</span>
</span>
</button>
<button mat-menu-item (click)="onDeleteActivity(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer':
hasPermissionToOpenDetails &&
!row.isDraft &&
row.type !== 'FEE' &&
row.type !== 'INTEREST' &&
row.type !== 'ITEM' &&
row.type !== 'LIABILITY'
}"
(click)="onClickActivity(row)"
></tr>
</table>
</div>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none': (isLoading && !totalItems) || totalItems <= pageSize
}"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<div
*ngIf="
dataSource?.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</div>

View File

@ -1,9 +0,0 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.activities {
overflow-x: auto;
}
}

View File

@ -1,241 +0,0 @@
import { SelectionModel } from '@angular/cdk/collections';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { Subject, Subscription, takeUntil } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activities-table-lazy',
styleUrls: ['./activities-table-lazy.component.scss'],
templateUrl: './activities-table-lazy.component.html'
})
export class ActivitiesTableLazyComponent
implements AfterViewInit, OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: string;
@Input() dataSource: MatTableDataSource<Activity>;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToOpenDetails = true;
@Input() locale: string;
@Input() pageIndex: number;
@Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions = true;
@Input() showCheckbox = false;
@Input() showFooter = true;
@Input() showNameColumn = true;
@Input() sortColumn: string;
@Input() sortDirection: SortDirection;
@Input() sortDisabled = false;
@Input() totalItems = Number.MAX_SAFE_INTEGER;
@Output() activityDeleted = new EventEmitter<string>();
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() deleteAllActivities = new EventEmitter<void>();
@Output() export = new EventEmitter<string[]>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>();
@Output() importDividends = new EventEmitter<UniqueAsset>();
@Output() pageChanged = new EventEmitter<PageEvent>();
@Output() selectedActivities = new EventEmitter<Activity[]>();
@Output() sortChanged = new EventEmitter<Sort>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public defaultDateFormat: string;
public displayedColumns = [];
public endOfToday = endOfToday();
public hasDrafts = false;
public hasErrors = false;
public isAfter = isAfter;
public isLoading = true;
public isUUID = isUUID;
public routeQueryParams: Subscription;
public selectedRows = new SelectionModel<Activity>(true, []);
private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {}
public ngOnInit() {
if (this.showCheckbox) {
this.toggleAllRows();
this.selectedRows.changed
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((selectedRows) => {
this.selectedActivities.emit(selectedRows.source.selected);
});
}
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe((value: Sort) => {
this.sortChanged.emit(value);
});
}
public areAllRowsSelected() {
const numSelectedRows = this.selectedRows.selected.length;
const numTotalRows = this.dataSource.data.length;
return numSelectedRows === numTotalRows;
}
public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.displayedColumns = [
'select',
'importStatus',
'icon',
'nameWithSymbol',
'type',
'date',
'quantity',
'unitPrice',
'fee',
'value',
'currency',
'valueInBaseCurrency',
'account',
'comment',
'actions'
];
if (!this.showCheckbox) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'importStatus' && column !== 'select';
});
}
if (!this.showNameColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'nameWithSymbol';
});
}
if (this.dataSource) {
this.isLoading = false;
}
}
public onChangePage(page: PageEvent) {
this.pageChanged.emit(page);
}
public onClickActivity(activity: Activity) {
if (this.showCheckbox) {
if (!activity.error) {
this.selectedRows.toggle(activity);
}
} else if (
this.hasPermissionToOpenDetails &&
!activity.isDraft &&
activity.type !== 'FEE' &&
activity.type !== 'INTEREST' &&
activity.type !== 'ITEM' &&
activity.type !== 'LIABILITY'
) {
this.onOpenPositionDialog({
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
});
}
}
public onCloneActivity(aActivity: OrderWithAccount) {
this.activityToClone.emit(aActivity);
}
public onDeleteActivity(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this activity?`
);
if (confirmation) {
this.activityDeleted.emit(aId);
}
}
public onExport() {
this.export.emit();
}
public onExportDraft(aActivityId: string) {
this.exportDrafts.emit([aActivityId]);
}
public onExportDrafts() {
this.exportDrafts.emit(
this.dataSource.filteredData
.filter((activity) => {
return activity.isDraft;
})
.map((activity) => {
return activity.id;
})
);
}
public onDeleteAllActivities() {
this.deleteAllActivities.emit();
}
public onImport() {
this.import.emit();
}
public onImportDividends() {
this.importDividends.emit();
}
public onOpenComment(aComment: string) {
alert(aComment);
}
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}
public onUpdateActivity(aActivity: OrderWithAccount) {
this.activityToUpdate.emit(aActivity);
}
public toggleAllRows() {
this.areAllRowsSelected()
? this.selectedRows.clear()
: this.dataSource.data.forEach((row) => this.selectedRows.select(row));
this.selectedActivities.emit(this.selectedRows.selected);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,42 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { ActivitiesTableLazyComponent } from './activities-table-lazy.component';
@NgModule({
declarations: [ActivitiesTableLazyComponent],
exports: [ActivitiesTableLazyComponent],
imports: [
CommonModule,
GfActivityTypeModule,
GfNoTransactionsInfoModule,
GfSymbolIconModule,
GfSymbolModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfActivitiesTableLazyModule {}

View File

@ -1,11 +1,3 @@
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
/>
<div *ngIf="hasPermissionToCreateActivity" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
@ -27,7 +19,7 @@
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<button
mat-menu-item
[disabled]="dataSource.data.length === 0"
[disabled]="dataSource?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
@ -39,7 +31,7 @@
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource.data.length === 0"
[disabled]="dataSource?.data.length === 0"
(click)="onExport()"
>
<span class="align-items-center d-flex">
@ -77,11 +69,12 @@
class="gf-table w-100"
mat-table
matSort
matSortActive="date"
matSortDirection="desc"
[dataSource]="dataSource"
[matSortActive]="sortColumn"
[matSortDirection]="sortDirection"
[matSortDisabled]="sortDisabled"
>
<ng-container matColumnDef="select">
<ng-container matColumnDef="select" sticky>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<mat-checkbox
color="primary"
@ -102,7 +95,6 @@
(click)="$event.stopPropagation()"
></mat-checkbox>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="importStatus">
@ -119,67 +111,26 @@
<ion-icon class="text-danger" name="alert-circle-outline" />
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="count">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
></th>
<td
*matCellDef="let element; let i = index"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
{{
dataSource.data.length > pageSize
? dataSource.data.length - pageSize * pageIndex - i
: dataSource.data.length - i
}}
<ng-container matColumnDef="icon">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-symbol-icon
[dataSource]="element.SymbolProfile?.dataSource"
[symbol]="element.SymbolProfile?.symbol"
[tooltip]="element.SymbolProfile?.name"
/>
<div>{{ element.dataSource }}</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Date</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-activity-type [activityType]="element.type" />
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="SymbolProfile.symbol"
>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="d-flex align-items-center">
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="align-items-center d-flex line-height-1">
<div>
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
<span
@ -196,27 +147,25 @@
}}</small>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
mat-sort-header="SymbolProfile.currency"
>
<ng-container i18n>Currency</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Type</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.SymbolProfile?.currency }}
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-activity-type [activityType]="element.type" />
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Date</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
</ng-container>
@ -242,11 +191,6 @@
/>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="unitPrice">
@ -271,11 +215,6 @@
/>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="fee">
@ -300,15 +239,6 @@
/>
</div>
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalFees"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="value">
@ -316,7 +246,6 @@
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Value</ng-container>
</th>
@ -333,16 +262,18 @@
/>
</div>
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalValue"
/>
</div>
</ng-container>
<ng-container matColumnDef="currency">
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
<ng-container i18n>Currency</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.SymbolProfile?.currency }}
</td>
</ng-container>
@ -351,7 +282,6 @@
*matHeaderCellDef
class="d-lg-none d-xl-none justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Value</ng-container>
</th>
@ -364,26 +294,10 @@
/>
</div>
</td>
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalValue"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="account">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="Account.name"
>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -397,7 +311,6 @@
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="comment">
@ -421,11 +334,6 @@
<ion-icon name="document-text-outline" />
</button>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
@ -456,7 +364,7 @@
<button
*ngIf="hasPermissionToCreateActivity"
mat-menu-item
[disabled]="dataSource.data.length === 0"
[disabled]="dataSource?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
@ -468,7 +376,7 @@
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource.data.length === 0"
[disabled]="dataSource?.data.length === 0"
(click)="onExport()"
>
<span class="align-items-center d-flex">
@ -531,7 +439,6 @@
</button>
</mat-menu>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
@ -549,28 +456,9 @@
}"
(click)="onClickActivity(row)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{
'd-none':
isLoading || dataSource.data.length === 0 || showFooter === false
}"
></tr>
</table>
</div>
<mat-paginator
[ngClass]="{
'd-none':
(isLoading && dataSource.data.length === 0) ||
dataSource.data.length <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
@ -581,9 +469,20 @@
}"
/>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none': (isLoading && !totalItems) || totalItems <= pageSize
}"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
dataSource?.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>

View File

@ -5,15 +5,5 @@
.activities {
overflow-x: auto;
.mat-mdc-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
}
}
}

View File

@ -1,5 +1,6 @@
import { SelectionModel } from '@angular/cdk/collections';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
EventEmitter,
@ -11,20 +12,17 @@ import {
ViewChild
} from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, format, isAfter } from 'date-fns';
import { get, isNumber } from 'lodash';
import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
import { endOfToday, isAfter } from 'date-fns';
import { Subject, Subscription, takeUntil } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -32,63 +30,56 @@ import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
styleUrls: ['./activities-table.component.scss'],
templateUrl: './activities-table.component.html'
})
export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() activities: Activity[];
export class ActivitiesTableComponent
implements AfterViewInit, OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: string;
@Input() dataSource: MatTableDataSource<Activity>;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilter = true;
@Input() hasPermissionToOpenDetails = true;
@Input() locale: string;
@Input() pageIndex: number;
@Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions = true;
@Input() showCheckbox = false;
@Input() showFooter = true;
@Input() showNameColumn = true;
@Input() sortColumn: string;
@Input() sortDirection: SortDirection;
@Input() sortDisabled = false;
@Input() totalItems = Number.MAX_SAFE_INTEGER;
@Output() activityDeleted = new EventEmitter<string>();
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() deleteAllActivities = new EventEmitter<void>();
@Output() export = new EventEmitter<string[]>();
@Output() export = new EventEmitter<void>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>();
@Output() importDividends = new EventEmitter<UniqueAsset>();
@Output() pageChanged = new EventEmitter<PageEvent>();
@Output() selectedActivities = new EventEmitter<Activity[]>();
@Output() sortChanged = new EventEmitter<Sort>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public allFilters: Filter[];
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns = [];
public endOfToday = endOfToday();
public filters$ = new Subject<Filter[]>();
public hasDrafts = false;
public hasErrors = false;
public isAfter = isAfter;
public isLoading = true;
public isUUID = isUUID;
public pageIndex = 0;
public placeholder = '';
public routeQueryParams: Subscription;
public searchKeywords: string[] = [];
public selectedRows = new SelectionModel<Activity>(true, []);
public totalFees: number;
public totalValue: number;
private readonly SEARCH_STRING_SEPARATOR = ',';
private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.subscribe((filters) => {
this.updateFilters(filters);
});
}
public constructor(private router: Router) {}
public ngOnInit() {
if (this.showCheckbox) {
@ -101,6 +92,12 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe((value: Sort) => {
this.sortChanged.emit(value);
});
}
public areAllRowsSelected() {
const numSelectedRows = this.selectedRows.selected.length;
const numTotalRows = this.dataSource.data.length;
@ -108,13 +105,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
}
public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.displayedColumns = [
'select',
'importStatus',
'count',
'date',
'type',
'icon',
'nameWithSymbol',
'type',
'date',
'quantity',
'unitPrice',
'fee',
@ -126,11 +125,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
'actions'
];
if (this.showCheckbox) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'count';
});
} else {
if (!this.showCheckbox) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'importStatus' && column !== 'select';
});
@ -142,60 +137,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
});
}
this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) {
this.activities = this.activities.map((activity) => {
return {
...activity,
error: activity.error
? {
...activity.error,
message: translate(
`IMPORT_ACTIVITY_ERROR_${activity.error.code}`
)
}
: undefined
};
});
this.allFilters = this.getSearchableFieldValues(this.activities);
this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => {
const filterableLabels = this.getFilterableValues(data).map(
({ label }) => {
return label.toLowerCase();
}
);
let includes = true;
for (const singleFilter of filter.split(this.SEARCH_STRING_SEPARATOR)) {
includes =
includes &&
filterableLabels.includes(singleFilter.trim().toLowerCase());
}
return includes;
};
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.updateFilters();
this.hasErrors = this.activities.some(({ error }) => {
return !!error;
});
} else {
this.hasErrors = false;
if (this.dataSource) {
this.isLoading = false;
}
}
public onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
this.pageChanged.emit(page);
}
public onClickActivity(activity: Activity) {
@ -233,15 +181,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
}
public onExport() {
if (this.searchKeywords.length > 0) {
this.export.emit(
this.dataSource.filteredData.map((activity) => {
return activity.id;
})
);
} else {
this.export.emit();
}
this.export.emit();
}
public onExportDraft(aActivityId: string) {
@ -298,145 +238,4 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private getFilterableValues(
activity: OrderWithAccount,
fieldValueMap: { [id: string]: Filter } = {}
): Filter[] {
if (activity.Account?.id) {
fieldValueMap[activity.Account.id] = {
id: activity.Account.id,
label: activity.Account.name,
type: 'ACCOUNT'
};
}
if (activity.SymbolProfile?.currency) {
fieldValueMap[activity.SymbolProfile.currency] = {
id: activity.SymbolProfile.currency,
label: activity.SymbolProfile.currency,
type: 'TAG'
};
}
if (
activity.SymbolProfile?.symbol &&
!isUUID(activity.SymbolProfile.symbol)
) {
fieldValueMap[activity.SymbolProfile.symbol] = {
id: activity.SymbolProfile.symbol,
label: activity.SymbolProfile.symbol,
type: 'SYMBOL'
};
}
fieldValueMap[activity.type] = {
id: activity.type,
label: activity.type,
type: 'TAG'
};
fieldValueMap[format(new Date(activity.date), 'yyyy')] = {
id: format(new Date(activity.date), 'yyyy'),
label: format(new Date(activity.date), 'yyyy'),
type: 'TAG'
};
return Object.values(fieldValueMap);
}
private getPaginatedData() {
if (this.dataSource.data.length > this.pageSize) {
const sortedData = this.dataSource.sortData(
this.dataSource.filteredData,
this.dataSource.sort
);
return sortedData.slice(
this.pageIndex * this.pageSize,
(this.pageIndex + 1) * this.pageSize
);
}
return this.dataSource.filteredData;
}
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
const fieldValueMap: { [id: string]: Filter } = {};
for (const activity of activities) {
this.getFilterableValues(activity, fieldValueMap);
}
return Object.values(fieldValueMap);
}
private getTotalFees() {
let totalFees = new Big(0);
const paginatedData = this.getPaginatedData();
for (const activity of paginatedData) {
if (isNumber(activity.feeInBaseCurrency)) {
totalFees = totalFees.plus(activity.feeInBaseCurrency);
} else {
return null;
}
}
return totalFees.toNumber();
}
private getTotalValue() {
const paginatedData = this.getPaginatedData();
let totalValue = new Big(0);
for (const { type, valueInBaseCurrency } of paginatedData) {
if (isNumber(valueInBaseCurrency)) {
if (type === 'BUY' || type === 'ITEM') {
totalValue = totalValue.plus(valueInBaseCurrency);
} else if (
type === 'DIVIDEND' ||
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY' ||
type === 'SELL'
) {
return null;
}
} else {
return null;
}
}
return totalValue.toNumber();
}
private updateFilters(filters: Filter[] = []) {
this.isLoading = true;
this.dataSource.filter = filters
.map((filter) => {
return filter.label;
})
.join(this.SEARCH_STRING_SEPARATOR);
const lowercaseSearchKeywords = filters.map((filter) => {
return filter.label.trim().toLowerCase();
});
this.placeholder =
lowercaseSearchKeywords.length <= 0
? $localize`Filter by account, currency, symbol or type...`
: '';
this.searchKeywords = filters.map((filter) => {
return filter.label;
});
this.hasDrafts = this.dataSource.filteredData.some((activity) => {
return activity.isDraft === true;
});
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
this.isLoading = false;
}
}

View File

@ -10,7 +10,6 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -23,7 +22,6 @@ import { ActivitiesTableComponent } from './activities-table.component';
exports: [ActivitiesTableComponent],
imports: [
CommonModule,
GfActivitiesFilterModule,
GfActivityTypeModule,
GfNoTransactionsInfoModule,
GfSymbolIconModule,

View File

@ -22,7 +22,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Account, Tag } from '@prisma/client';
import { Account, AssetClass } from '@prisma/client';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import {
catchError,
@ -92,6 +92,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public accounts: Account[] = [];
public assetClasses: Filter[] = [];
public dateRangeFormControl = new FormControl<string>(undefined);
public readonly dateRangeOptions = [
{ label: $localize`Today`, value: '1d' },
@ -107,12 +108,16 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
label: $localize`Year to date` + ' (' + $localize`YTD` + ')',
value: 'ytd'
},
{ label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' },
{ label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', value: '1y' },
{
label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')',
value: '5y'
},
{ label: $localize`Max`, value: 'max' }
];
public filterForm = this.formBuilder.group({
account: new FormControl<string>(undefined),
assetClass: new FormControl<string>(undefined),
tag: new FormControl<string>(undefined)
});
public isLoading = false;
@ -123,8 +128,9 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
assetProfiles: [],
holdings: []
};
public tags: Tag[] = [];
public tags: Filter[] = [];
private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG'];
private keyManager: FocusKeyManager<AssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>();
@ -136,32 +142,21 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
) {}
public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.accounts = this.user?.accounts;
this.tags = tags.map(({ id, name }) => {
this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return {
id,
name: translate(name)
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
};
});
this.tags = this.user?.tags.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ account, tag }) => {
this.filtersChanged.emit([
{
id: account,
type: 'ACCOUNT'
},
{
id: tag,
type: 'TAG'
}
]);
this.onCloseAssistant();
});
this.searchFormControl.valueChanges
.pipe(
@ -208,6 +203,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterForm.setValue(
{
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
},
{
@ -245,6 +241,25 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
public onApplyFilters() {
this.filtersChanged.emit([
{
id: this.filterForm.get('account').value,
type: 'ACCOUNT'
},
{
id: this.filterForm.get('assetClass').value,
type: 'ASSET_CLASS'
},
{
id: this.filterForm.get('tag').value,
type: 'TAG'
}
]);
this.onCloseAssistant();
}
public onChangeDateRange(dateRangeString: string) {
this.dateRangeChanged.emit(dateRangeString as DateRange);
}
@ -256,16 +271,14 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
}
public onResetFilters() {
this.filtersChanged.emit([
{
id: null,
type: 'ACCOUNT'
},
{
id: null,
type: 'TAG'
}
]);
this.filtersChanged.emit(
this.filterTypes.map((type) => {
return {
type,
id: null
};
})
);
this.onCloseAssistant();
}

View File

@ -1,169 +1,172 @@
<div
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
(click)="$event.stopPropagation();"
(keydown.tab)="$event.stopPropagation()"
>
<div class="align-items-center d-flex search-container">
<ion-icon class="ml-2 mr-0" name="search-outline" />
<input
#search
autocomplete="off"
autocorrect="off"
class="border-0 p-2 w-100"
name="search"
type="text"
[formControl]="searchFormControl"
[placeholder]="placeholder"
/>
<div
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
class="hot-key-hint mx-1 px-1"
>
/
</div>
<button
*ngIf="searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="initialize()"
>
<ion-icon class="m-0" name="close-circle-outline" />
</button>
<button
*ngIf="!searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="onCloseAssistant()"
>
<ion-icon class="m-0" name="close-outline" />
</button>
</div>
<div (click)="$event.stopPropagation()">
<div
*ngIf="isLoading || searchFormControl.value"
class="overflow-auto py-3 result-container"
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
(keydown.tab)="$event.stopPropagation()"
>
<div>
<div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.holdings"
mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
<div class="align-items-center d-flex search-container">
<ion-icon class="ml-2 mr-0" name="search-outline" />
<input
#search
autocomplete="off"
autocorrect="off"
class="border-0 p-2 w-100"
name="search"
type="text"
[formControl]="searchFormControl"
[placeholder]="placeholder"
/>
<ng-container *ngIf="searchResults?.holdings?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div>
</div>
<form [formGroup]="filterForm">
<div
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
class="filter-container"
>
<div class="p-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Date Range</mat-label>
<mat-select
[formControl]="dateRangeFormControl"
(selectionChange)="onChangeDateRange($event.value)"
>
@for (range of dateRangeOptions; track range) {
<mat-option [value]="range.value">{{ range.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-tab-group
animationDuration="0"
mat-align-tabs="start"
[mat-stretch-tabs]="false"
(click)="$event.stopPropagation();"
>
<mat-tab>
<ng-template mat-tab-label
><ion-icon name="albums-outline" /><span
class="d-none d-sm-block ml-2"
i18n
>Accounts</span
></ng-template
>
<div class="p-3">
<mat-radio-group color="primary" formControlName="account">
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No account</mat-radio-button
>
@for (account of accounts; track account.id) {
<mat-radio-button class="d-flex flex-column" [value]="account.id"
>{{ account.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label
><ion-icon name="pricetag-outline" /><span
class="d-none d-sm-block ml-2"
i18n
>Tags</span
></ng-template
>
<div class="p-3">
<mat-radio-group color="primary" formControlName="tag">
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No tag</mat-radio-button
>
@for (tag of tags; track tag.id) {
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
>{{ tag.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
</mat-tab-group>
<div class="p-3">
<button
class="w-100"
color="primary"
i18n
mat-flat-button
[disabled]="!hasFilter(filterForm.value)"
(click)="onResetFilters()"
<div
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
class="hot-key-hint mx-1 px-1"
>
Reset Filters
/
</div>
<button
*ngIf="searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="initialize()"
>
<ion-icon class="m-0" name="close-circle-outline" />
</button>
<button
*ngIf="!searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="onCloseAssistant()"
>
<ion-icon class="m-0" name="close-outline" />
</button>
</div>
<div
*ngIf="isLoading || searchFormControl.value"
class="overflow-auto py-3 result-container"
>
<div>
<div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.holdings"
mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.holdings?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div>
</div>
</form>
<form [formGroup]="filterForm">
<ng-container *ngIf="!(isLoading || searchFormControl.value)">
<div class="date-range-selector-container p-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Date Range</mat-label>
<mat-select
[formControl]="dateRangeFormControl"
(selectionChange)="onChangeDateRange($event.value)"
>
@for (range of dateRangeOptions; track range) {
<mat-option [value]="range.value">{{ range.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="p-3">
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Accounts</mat-label>
<mat-select formControlName="account">
<mat-option [value]="null"></mat-option>
@for (account of accounts; track account.id) {
<mat-option [value]="account.id">
<div class="d-flex">
<gf-symbol-icon
*ngIf="account.Platform?.url"
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
/><span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-select formControlName="tag">
<mat-option [value]="null"></mat-option>
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Classes</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id"
>{{ assetClass.label }}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
<div class="d-flex w-100">
<button
i18n
mat-button
[disabled]="!hasFilter(filterForm.value)"
(click)="onResetFilters()"
>
Reset Filters
</button>
<span class="gf-spacer"></span>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!filterForm.dirty"
(click)="onApplyFilters()"
>
Apply Filters
</button>
</div>
</div>
</ng-container>
</form>
</div>

View File

@ -3,10 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfAssistantListItemModule } from './assistant-list-item/assistant-list-item.module';
@ -19,11 +18,10 @@ import { AssistantComponent } from './assistant.component';
CommonModule,
FormsModule,
GfAssistantListItemModule,
GfSymbolIconModule,
MatButtonModule,
MatFormFieldModule,
MatRadioModule,
MatSelectModule,
MatTabsModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule,
RouterModule

View File

@ -1,16 +1,8 @@
:host {
display: block;
.filter-container {
.mat-mdc-tab-group {
max-height: 20vh;
}
::ng-deep {
label {
margin-bottom: 0;
}
}
.date-range-selector-container {
border-bottom: 1px solid rgba(var(--dark-dividers));
}
.result-container {
@ -35,6 +27,10 @@
}
:host-context(.is-dark-theme) {
.date-range-selector-container {
border-color: rgba(var(--light-dividers));
}
.search-container {
border-color: rgba(var(--light-dividers));

View File

@ -15,12 +15,15 @@
<mat-option
*ngFor="let lookupItem of filteredLookupItems"
class="line-height-1"
[disabled]="lookupItem.dataProviderInfo.isPremium"
[value]="lookupItem"
>
<span
><b>{{ lookupItem.name }}</b></span
>
<br />
<span class="align-items-center d-flex line-height-1"
><b>{{ lookupItem.name }}</b>
@if (lookupItem.dataProviderInfo.isPremium) {
<gf-premium-indicator class="ml-1" [enableLink]="false" />
}
</span>
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
}}<ng-container *ngIf="lookupItem.assetSubClass">

View File

@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
@NgModule({
declarations: [SymbolAutocompleteComponent],
@ -14,6 +15,7 @@ import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/
imports: [
CommonModule,
FormsModule,
GfPremiumIndicatorModule,
GfSymbolModule,
MatAutocompleteModule,
MatFormFieldModule,

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.46.0",
"version": "2.49.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -133,7 +133,7 @@
"svgmap": "2.6.0",
"twitter-api-v2": "1.14.2",
"uuid": "9.0.1",
"yahoo-finance2": "2.9.0",
"yahoo-finance2": "2.9.1",
"zone.js": "0.14.2"
},
"devDependencies": {
@ -191,7 +191,7 @@
"jest-environment-jsdom": "29.4.3",
"jest-preset-angular": "13.1.4",
"nx": "17.2.8",
"prettier": "3.2.1",
"prettier": "3.2.5",
"prettier-plugin-organize-attributes": "1.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",

View File

@ -16945,10 +16945,10 @@ prettier-plugin-organize-attributes@1.0.0:
resolved "https://registry.yarnpkg.com/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz#037870ee3111b3c1d6371f677b64888de353cc63"
integrity sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg==
prettier@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.1.tgz#babf33580e16c796a9740b9fae551624f7bfeaab"
integrity sha512-qSUWshj1IobVbKc226Gw2pync27t0Kf0EdufZa9j7uBSJay1CC+B3K5lAAZoqgX3ASiKuWsk6OmzKRetXNObWg==
prettier@3.2.5:
version "3.2.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
prettier@^2.8.0:
version "2.8.8"
@ -20184,10 +20184,10 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance2@2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.9.0.tgz#7842580de36606197f7d64897dd2e5e55b9371d3"
integrity sha512-Q1UhB5uA0Uj2bBcSDqsZLt0tCxoHwrWCuvu4NMUgioyN8dlpq8ppbdKhZlzTD9ipIyKSgqG5TT7IlwB1x6eHZA==
yahoo-finance2@2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.9.1.tgz#43e22465403f48c688ff8e762f3894aac8014d70"
integrity sha512-s+i5arE6+zUwHRJnze4EsU5aCTmsMFKFeBc9sMzSceDOjH+BSeEZG9twMYtWlSCjKbWLCmUEUCxtH1fvcq+f6Q==
dependencies:
"@types/tough-cookie" "^4.0.2"
ajv "8.10.0"