Compare commits

...

43 Commits

Author SHA1 Message Date
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
21173bed21 Release 1.145.0 (#884) 2022-05-07 11:47:28 +02:00
16dd8f7652 Feature/refactor filters with interface (#883)
* Refactor filtering with an interface

* Filter by accounts

* Update changelog
2022-05-07 11:44:29 +02:00
ce6b5fb7cb Bugfix/fix tooltip in proportion chart after update (#882)
* Keep tooltip configuration up to date

* Update changelog
2022-05-01 08:38:57 +02:00
f6f62db830 Feature/add support for private equity (#881)
* Add support for private equity

* Update changelog
2022-04-30 22:16:13 +02:00
01103f3db4 Feature/add asset and asset sub class to wealth items form (#880)
* Add asset and asset sub class

* Update changelog
2022-04-30 21:47:10 +02:00
e9e9f1a124 Release 1.144.0 (#879) 2022-04-30 11:51:49 +02:00
751256f158 Feature/add support for real estate and precious metal (#878)
* Add support for real estate and precious metal

* Update changelog
2022-04-30 11:49:58 +02:00
c2a1cbd20f Feature/improve layout of position detail dialog (#877)
* Improve layout

* Update changelog
2022-04-30 10:48:02 +02:00
04044f8720 Support futures (#845)
* Support futures

* Upgrade yahoo-finance2 to version 2.3.2

* Update changelog
2022-04-30 09:55:24 +02:00
4dc76817ce Bugfix/fix import validation for numbers equal zero (#875)
* Fix import validation for numbers equal 0

* Update changelog
2022-04-29 13:10:45 +02:00
1f0bd5a7db Bugfix/fix color of spinner in filter component (#873)
* Fix color for dark mode

* Update changelog
2022-04-27 17:30:57 +02:00
b6cd007ad4 Release/1.143.0 (#871)
* Release 1.143.0
  * Improve filtering by tags
2022-04-26 22:31:53 +02:00
b4bc72c6f9 Release 1.142.0 (#869) 2022-04-25 22:41:02 +02:00
899fa0370e Feature/improve users table of admin control panel (#866)
* Improve users table

* Update changelog
2022-04-25 22:39:08 +02:00
da27504aa1 Add orderBy statement to make debugging easier (#868) 2022-04-25 22:37:56 +02:00
b7bbc029ac Feature/render tags in dialogs (#864)
* Render tags

* Update changelog
2022-04-25 22:37:34 +02:00
c61a415fb2 Bugfix/change date to utc in data gathering service (#867)
* Change date to UTC

* Update changelog
2022-04-25 18:12:42 +02:00
8ff811ed28 Release/1.141.1 (#863) 2022-04-24 17:27:00 +02:00
9a2ea0a4ed Release 1.141.0 (#862) 2022-04-24 16:24:21 +02:00
bad9d17c44 Setup allocations page and endpoint (#859)
* Setup tagging system

* Update changelog
2022-04-24 16:23:03 +02:00
ea89ca5734 Feature/simplify ids in database schema (#861)
* Simplify ids in database schema
  * Access
  * Order
  * Subscription

* Update changelog
2022-04-24 09:35:01 +02:00
8f61f7c169 Feature/extract activities table filter component (#858)
* Extract activities table component

* Update changelog
2022-04-23 19:22:20 +02:00
edca05f542 Feature/change get started url of public page (#857)
* Change url

* Update changelog
2022-04-23 16:48:23 +02:00
283f054ee2 Feature/upgrade prisma to version 3.12.0 (#856)
* Upgrade prisma to version 3.12.0

* Update changelog
2022-04-23 13:38:41 +02:00
e9a46cb224 Release 1.140.2 (#854) 2022-04-23 09:52:22 +02:00
4a75c6d483 Release 1.140.1 (#853) 2022-04-22 21:45:50 +02:00
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
98 changed files with 3498 additions and 2147 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,131 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.146.0 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
## 1.145.0 - 07.05.2022
### Added
- Added support for filtering by accounts on the allocations page
- Added support for private equity
- Extended the form to set the asset and asset sub class for (wealth) items
### Changed
- Refactored the filtering (activities table and allocations page)
### Fixed
- Fixed the tooltip update in the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.144.0 - 30.04.2022
### Added
- Added support for commodities (via futures)
- Added support for real estate
### Changed
- Improved the layout of the position detail dialog
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
### Fixed
- Fixed the import validation for numbers equal 0
- Fixed the color of the spinner in the activities filter component (dark mode)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.143.0 - 26.04.2022
### Changed
- Improved the filtering by tags
## 1.142.0 - 25.04.2022
### Added
- Added the tags to the create or edit transaction dialog
- Added the tags to the position detail dialog
### Changed
- Changed the date to UTC in the data gathering service
- Reused the value component in the users table of the admin control panel
## 1.141.1 - 24.04.2022
### Added
- Added the database migration
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.141.0 - 24.04.2022
### Added
- Added a tagging system for activities
### Changed
- Extracted the activities table filter to a dedicated component
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.140.2 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022 ## 1.139.0 - 18.04.2022
### Added ### Added
@ -308,7 +433,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3` - Upgraded `angular` from version `13.1.2` to `13.2.2`
- Upgraded `Nx` from version `13.4.1` to `13.8.1` - Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18` - Upgraded `storybook` from version `6.4.9` to `6.4.18`

View File

@ -246,6 +246,8 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License
© 2022 [Ghostfolio](https://ghostfol.io) © 2022 [Ghostfolio](https://ghostfol.io)

View File

@ -47,7 +47,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.js", "jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
@ -180,7 +180,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.js", "jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/common/jest.config.js", "jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
} }
@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"], "outputs": ["coverage/libs/ui"],
"options": { "options": {
"jestConfig": "libs/ui/jest.config.js", "jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
}, },

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'api', displayName: 'api',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json' tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000, testTimeout: 10000,
testEnvironment: 'node' testEnvironment: 'node',
preset: '../../jest.preset.ts'
}; };

View File

@ -78,8 +78,12 @@ export class AccessController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
const access = await this.accessService.access({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess) !hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -88,10 +92,7 @@ export class AccessController {
} }
return this.accessService.deleteAccess({ return this.accessService.deleteAccess({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
} }

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -8,6 +12,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { import {
Body, Body,
Controller, Controller,
@ -23,6 +28,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,6 +39,8 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -71,10 +79,16 @@ export class AdminController {
); );
} }
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
this.dataGatheringService.gatherMax();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
this.dataGatheringService.gatherMax();
} }
@Post('gather/profile-data') @Post('gather/profile-data')
@ -92,9 +106,14 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@ -115,9 +134,10 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]); await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
return; symbol
});
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')

View File

@ -15,7 +15,7 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client'; import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()

View File

@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -36,6 +37,12 @@ import { UserModule } from './user/user.module';
AccountModule, AccountModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10)
}
}),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule,
TagModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID, DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -33,7 +34,8 @@ export class InfoService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -105,7 +107,8 @@ export class InfoService {
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
}; };
} }

View File

@ -1,4 +1,4 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
IsEnum, IsEnum,
IsISO8601, IsISO8601,
@ -10,14 +10,22 @@ import {
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
@IsOptional() @IsOptional()
dataSource: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;

View File

@ -42,8 +42,12 @@ export class OrderController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) !hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -52,10 +56,7 @@ export class OrderController {
} }
return this.orderService.deleteOrder({ return this.orderService.deleteOrder({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
@ -135,23 +136,15 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
if (!originalOrder) { if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -178,15 +171,17 @@ export class OrderController {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id
id,
userId: this.request.user.id
}
} }
}); });
} }

View File

@ -4,11 +4,26 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataSource,
Order,
Prisma,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activity } from './interfaces/activities.interface';
@ -18,6 +33,8 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -55,6 +72,8 @@ export class OrderService {
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -77,6 +96,8 @@ export class OrderService {
}; };
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
const currency = data.SymbolProfile.connectOrCreate.create.currency; const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
@ -84,6 +105,8 @@ export class OrderService {
Account = undefined; Account = undefined;
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
@ -97,12 +120,10 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.gatherProfileData([ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
{ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol
symbol: data.SymbolProfile.connectOrCreate.create.symbol });
}
]);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -120,6 +141,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.accountId; delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
@ -151,11 +174,13 @@ export class OrderService {
} }
public async getOrders({ public async getOrders({
filters,
includeDrafts = false, includeDrafts = false,
types, types,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
@ -163,10 +188,35 @@ export class OrderService {
}): Promise<Activity[]> { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; where.isDraft = false;
} }
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {
@ -188,7 +238,8 @@ export class OrderService {
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true SymbolProfile: true,
tags: true
}, },
orderBy: { date: 'asc' } orderBy: { date: 'asc' }
}) })
@ -217,6 +268,8 @@ export class OrderService {
where where
}: { }: {
data: Prisma.OrderUpdateInput & { data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -230,10 +283,10 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const name = data.SymbolProfile.connect.dataSource_symbol.symbol; delete data.SymbolProfile.connect;
data.SymbolProfile = { update: { name } };
} else { } else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
@ -250,6 +303,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;

View File

@ -1,10 +1,24 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator'; import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,5 +1,6 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
orders: OrderWithAccount[]; orders: OrderWithAccount[];
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number; transactionCount: number;
value: number; value: number;
} }

View File

@ -11,6 +11,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
@ -19,7 +20,7 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -105,15 +106,36 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('accounts') filterByAccounts?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'account'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'tag'
};
})
];
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
this.request.user.id, this.request.user.id,
range range,
filters
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -159,7 +181,11 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'; this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings }; return {
hasError,
accounts: filters.length === 0 ? accounts : {},
holdings: isBasicUser ? {} : holdings
};
} }
@Get('investments') @Get('investments')

View File

@ -29,6 +29,7 @@ import {
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
Filter,
PortfolioDetails, PortfolioDetails,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReport,
@ -46,7 +47,12 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
DataSource,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
@ -62,7 +68,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy } from 'lodash'; import { isEmpty, sortBy, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -303,7 +309,8 @@ export class PortfolioService {
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string, aUserId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max',
aFilters?: Filter[]
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId); const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -318,7 +325,8 @@ export class PortfolioService {
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
filters: aFilters
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -441,8 +449,10 @@ export class PortfolioService {
value: totalValue value: totalValue
}); });
for (const symbol of Object.keys(cashPositions)) { if (aFilters === undefined) {
holdings[symbol] = cashPositions[symbol]; for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts(
@ -472,8 +482,11 @@ export class PortfolioService {
); );
}); });
let tags: Tag[] = [];
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
tags,
averagePrice: undefined, averagePrice: undefined,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -500,6 +513,8 @@ export class PortfolioService {
const portfolioOrders: PortfolioOrder[] = orders const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return order.type === 'BUY' || order.type === 'SELL';
}) })
.map((order) => ({ .map((order) => ({
@ -514,6 +529,8 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
tags = uniqBy(tags, 'id');
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: positionCurrency, currency: positionCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -622,6 +639,7 @@ export class PortfolioService {
netPerformance, netPerformance,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: grossPerformancePercent:
@ -678,6 +696,7 @@ export class PortfolioService {
minPrice, minPrice,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
averagePrice: 0, averagePrice: 0,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -1177,9 +1196,11 @@ export class PortfolioService {
} }
private async getTransactionPoints({ private async getTransactionPoints({
filters,
includeDrafts = false, includeDrafts = false,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
userId: string; userId: string;
}): Promise<{ }): Promise<{
@ -1190,6 +1211,7 @@ export class PortfolioService {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters,
includeDrafts, includeDrafts,
userCurrency, userCurrency,
userId, userId,

View File

@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
locale?: string; locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
} }

View File

@ -34,7 +34,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService

View File

@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -19,7 +20,8 @@ import { UserService } from './user.service';
}), }),
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule,
TagModule
], ],
providers: [UserService] providers: [UserService]
}) })

View File

@ -2,6 +2,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
baseCurrency, baseCurrency,
@ -13,7 +14,6 @@ import {
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
@ -30,7 +30,8 @@ export class UserService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {} ) {}
public async getUser( public async getUser(
@ -51,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } }, orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return { return {
alias, alias,
id, id,
permissions, permissions,
subscription, subscription,
tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,

View File

@ -1,5 +1,11 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -8,6 +14,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
@ -30,6 +38,13 @@ export class CronService {
@Cron(CronExpression.EVERY_WEEKEND) @Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() { public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
} }

View File

@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module'; import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule, ConfigurationModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringModule {}

View File

@ -0,0 +1,27 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { DataGatheringService } from './data-gathering.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
}
}
}

View File

@ -226,28 +226,29 @@ export class DataGatheringService {
} }
} }
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
Logger.log( let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
'Profile data gathering has been started.', return dataGatheringItem.dataSource !== 'MANUAL';
'DataGatheringService' });
);
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems?.filter( if (!uniqueAssets) {
(dataGatheringItem) => { uniqueAssets = await this.getUniqueAssets();
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
} }
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems uniqueAssets
); );
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => { uniqueAssets.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
@ -322,10 +323,13 @@ export class DataGatheringService {
} }
Logger.log( Logger.log(
'Profile data gathering has been completed.', `Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService' 'DataGatheringService'
); );
console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -377,7 +381,14 @@ export class DataGatheringService {
data: { data: {
dataSource, dataSource,
symbol, symbol,
date: currentDate, date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice marketPrice: lastMarketPrice
} }
}); });
@ -501,6 +512,27 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService'); Logger.log('Data gathering has been reset.', 'DataGatheringService');
@ -537,6 +569,7 @@ export class DataGatheringService {
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate } date: { gt: startDate }
} }
@ -576,27 +609,6 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();

View File

@ -20,10 +20,7 @@ import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import type { import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
Price,
QuoteSummaryResult
} from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
@ -92,7 +89,12 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency; response.currency = assetProfile.price.currency;
response.dataSource = this.getName(); response.dataSource = this.getName();
response.name = this.formatName(assetProfile); response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol; response.symbol = aSymbol;
if ( if (
@ -247,7 +249,7 @@ export class YahooFinanceService implements DataProviderInterface {
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
// filter out undefined symbols // Filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
@ -256,7 +258,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) || )) ||
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType) ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
@ -264,6 +266,9 @@ export class YahooFinanceService implements DataProviderInterface {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency); return symbol.includes(baseCurrency);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;
} }
return true; return true;
@ -288,7 +293,12 @@ export class YahooFinanceService implements DataProviderInterface {
symbol, symbol,
currency: marketDataItem.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol name: this.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
symbol: quote.symbol
})
}); });
} }
} catch (error) { } catch (error) {
@ -298,8 +308,18 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private formatName(aAssetProfile: QuoteSummaryResult) { private formatName({
let name = aAssetProfile.price.longName; longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) { if (name) {
name = name.replace('iShares ETF (CH) - ', ''); name = name.replace('iShares ETF (CH) - ', '');
@ -314,7 +334,12 @@ export class YahooFinanceService implements DataProviderInterface {
name = name.replace('Xtrackers (IE) Plc - ', ''); name = name.replace('Xtrackers (IE) Plc - ', '');
} }
return name || aAssetProfile.price.shortName || aAssetProfile.price.symbol; if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
} }
private parseAssetClass(aPrice: Price): { private parseAssetClass(aPrice: Price): {
@ -336,6 +361,20 @@ export class YahooFinanceService implements DataProviderInterface {
case 'etf': case 'etf':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF; assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break; break;
case 'mutualfund': case 'mutualfund':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;

View File

@ -4,7 +4,12 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@ -36,6 +41,7 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true },
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -45,14 +51,38 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] { private getSymbols(
return symbolProfiles.map((symbolProfile) => ({ symbolProfiles: (SymbolProfile & {
...symbolProfile, SymbolProfileOverrides: SymbolProfileOverrides;
countries: this.getCountries(symbolProfile), })[]
scraperConfiguration: this.getScraperConfiguration(symbolProfile), ): EnhancedSymbolProfile[] {
sectors: this.getSectors(symbolProfile), return symbolProfiles.map((symbolProfile) => {
symbolMapping: this.getSymbolMapping(symbolProfile) const item = {
})); ...symbolProfile,
countries: this.getCountries(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
};
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
item.countries;
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
delete item.SymbolProfileOverrides;
}
return item;
});
} }
private getCountries(symbolProfile: SymbolProfile): Country[] { private getCountries(symbolProfile: SymbolProfile): Country[] {

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { TagService } from './tag.service';
@Module({
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
}
});
}
public async getByUser(userId: string) {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
},
where: {
orders: {
some: {
userId
}
}
}
});
}
}

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2015"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -5,5 +5,5 @@
"module": "commonjs", "module": "commonjs",
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'client', displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
transform: { transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'] transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
}; };

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
@ -15,6 +16,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public user: User;
public users: AdminData['users']; public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -24,8 +26,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -45,14 +45,27 @@
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }} {{ formatDistanceToNow(userItem.createdAt) }}
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.accountCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.transactionCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.engagement | number: '1.0-0' }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.lastActivity) }} {{ formatDistanceToNow(userItem.lastActivity) }}

View File

@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component'; import { AdminUsersComponent } from './admin-users.component';
@NgModule({ @NgModule({
declarations: [AdminUsersComponent], declarations: [AdminUsersComponent],
exports: [], exports: [],
imports: [CommonModule, MatButtonModule, MatMenuModule], imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminUsersModule {} export class GfAdminUsersModule {}

View File

@ -11,7 +11,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -48,6 +48,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public SymbolProfile: SymbolProfile; public SymbolProfile: SymbolProfile;
public tags: Tag[];
public transactionCount: number; public transactionCount: number;
public value: number; public value: number;
@ -83,6 +84,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
value value
}) => { }) => {
@ -115,6 +117,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.quantity = quantity; this.quantity = quantity;
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.value = value; this.value = value;

View File

@ -168,7 +168,7 @@
</ng-container> </ng-container>
<ng-template #charts> <ng-template #charts>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -179,7 +179,7 @@
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -192,23 +192,36 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
</div>
<gf-activities-table <div *ngIf="orders?.length > 0" class="row">
*ngIf="orders?.length > 0" <div class="col mb-3">
[activities]="orders" <div class="h5 mb-0" i18n>Activities</div>
[baseCurrency]="data.baseCurrency" <gf-activities-table
[deviceType]="data.deviceType" [activities]="orders"
[hasPermissionToCreateActivity]="false" [baseCurrency]="data.baseCurrency"
[hasPermissionToExportActivities]="!hasImpersonationId" [deviceType]="data.deviceType"
[hasPermissionToFilter]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToOpenDetails]="false" [hasPermissionToFilter]="false"
[locale]="data.locale" [hasPermissionToImportActivities]="false"
[showActions]="false" [hasPermissionToOpenDetails]="false"
[showSymbolColumn]="false" [locale]="data.locale"
(export)="onExport()" [showActions]="false"
></gf-activities-table> [showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list>
</div>
</div>
</div>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
@ -24,6 +25,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDialogModule, MatDialogModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -22,7 +22,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;
public version = environment.version; public version = environment.version;

View File

@ -2,103 +2,101 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="about-container"> <div class="about-container">
<mat-card-content> <p>
<p> <strong>Ghostfolio</strong> is a lightweight wealth management
<strong>Ghostfolio</strong> is a lightweight wealth management application for individuals to keep track of stocks, ETFs or
application for individuals to keep track of their wealth like cryptocurrencies and make solid, data-driven investment decisions. The
stocks, ETFs or cryptocurrencies and make solid, data-driven source code is fully available as open source software (OSS). The
investment decisions. The source code is fully available as open project has been initiated by
source software (OSS). The project has been initiated by <a href="https://dotsilver.ch" title="Website of Thomas Kaul"
<a href="https://dotsilver.ch" title="Website of Thomas Kaul" >Thomas Kaul</a
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new <a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
> >
<div and is driven by the efforts of its
class="independent-and-bootstrapped-logo mb-2" <a
title="Ghostfolio is an independent & bootstrapped business" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
></div> title="Contributors to Ghostfolio"
</div> >contributors</a
</mat-card-content> >.
</mat-card> <ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</div>
</div> </div>
</div> </div>
@ -109,38 +107,39 @@
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 24 hours)</small subLabel="(Last 24 hours)"
> [value]="statistics?.activeUsers1d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="New Users"
<span i18n>New Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.newUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.activeUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a <a
class="d-block" class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.slackCommunityUsers ?? '-' }} label="Users in Slack community"
</h3> size="large"
<div class="h6 mb-0" i18n>Users in Slack community</div> [value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -148,10 +147,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.gitHubContributors ?? '-' }} label="Contributors on GitHub"
</h3> size="large"
<div class="h6 mb-0" i18n>Contributors on GitHub</div> [value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -159,8 +159,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3> <gf-value
<div class="h6 mb-0" i18n>Stars on GitHub</div> label="Stars on GitHub"
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [ imports: [
AboutPageRoutingModule, AboutPageRoutingModule,
CommonModule, CommonModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule
], ],

View File

@ -2,15 +2,13 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card { .about-container {
&.about-container { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
} }
} }
@ -29,7 +27,7 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.mat-card { .about-container {
.independent-and-bootstrapped-logo { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg'); background-image: url('/assets/bootstrapped-light.svg');
opacity: 1; opacity: 1;

View File

@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
UniqueAsset, UniqueAsset,
@ -17,7 +18,7 @@ import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -32,6 +33,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public allFilters: Filter[];
public continents: { public continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
@ -39,7 +41,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public deviceType: string; public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public isLoading = false;
public markets: { public markets: {
[key in Market]: { name: string; value: number }; [key in Market]: { name: string; value: number };
}; };
@ -48,6 +52,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' } { label: 'Current', value: 'current' }
]; ];
public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
public positions: { public positions: {
[symbol: string]: Pick< [symbol: string]: Pick<
@ -120,14 +125,23 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.dataService this.filters$
.fetchPortfolioDetails({}) .pipe(
.pipe(takeUntil(this.unsubscribeSubject)) distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
return this.dataService.fetchPortfolioDetails({ filters });
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => { .subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails; this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData(this.period); this.initializeAnalysisData(this.period);
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -137,12 +151,32 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts.map(
({ id, name }) => {
return {
id: id,
label: name,
type: 'account'
};
}
);
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: name,
type: 'tag'
};
});
this.allFilters = [...accountFilters, ...tagFilters];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public initializeAnalysisData(aPeriod: string) { public initialize() {
this.accounts = {}; this.accounts = {};
this.continents = { this.continents = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
@ -185,6 +219,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
}
public initializeAnalysisData(aPeriod: string) {
this.initialize();
for (const [id, { current, name, original }] of Object.entries( for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts this.portfolioDetails.accounts
@ -305,7 +343,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
if (position.assetClass === AssetClass.EQUITY) { if (position.dataSource) {
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource, dataSource: position.dataSource,
name: position.name, name: position.name,

View File

@ -2,6 +2,12 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; 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 { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [ imports: [
AllocationsPageRoutingModule, AllocationsPageRoutingModule,
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js'; import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -16,6 +17,7 @@ import { takeUntil } from 'rxjs/operators';
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public fireWealth: Big; public fireWealth: Big;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
@ -63,11 +65,23 @@ export class FirePageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -41,7 +41,7 @@
[value]="withdrawalRatePerMonth?.toNumber()" [value]="withdrawalRatePerMonth?.toNumber()"
></gf-value> ></gf-value>
per month</span per month</span
>, based on your investment of >, based on your total assets of
<gf-value <gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -59,7 +59,10 @@
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()" [fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator> ></gf-fire-calculator>
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject } from 'rxjs';
@ -39,13 +39,15 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass);
public assetSubClasses = Object.keys(AssetSubClass);
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators; public Validators = Validators;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -66,6 +68,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required], accountId: [this.data.activity?.accountId, Validators.required],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
currency: [ currency: [
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
@ -85,10 +89,30 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}, },
Validators.required Validators.required
], ],
tags: [this.data.activity?.tags],
type: [undefined, Validators.required], // Set after value changes subscription type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required] unitPrice: [this.data.activity?.unitPrice, Validators.required]
}); });
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
) {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0;
}
});
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol' 'searchSymbol'
].valueChanges.pipe( ].valueChanges.pipe(
@ -100,9 +124,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable = const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query); this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => { filteredLookupItemsObservable
this.filteredLookupItems = filteredLookupItems; .pipe(takeUntil(this.unsubscribeSubject))
}); .subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable; return filteredLookupItemsObservable;
} }
@ -111,45 +137,47 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}) })
); );
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => { this.activityForm.controls['type'].valueChanges
if (type === 'ITEM') { .pipe(takeUntil(this.unsubscribeSubject))
this.activityForm.controls['accountId'].removeValidators( .subscribe((type: Type) => {
Validators.required if (type === 'ITEM') {
); this.activityForm.controls['accountId'].removeValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['currency'].setValue( );
this.data.user.settings.baseCurrency this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['currency'].setValue(
this.activityForm.controls['dataSource'].removeValidators( this.data.user.settings.baseCurrency
Validators.required );
); this.activityForm.controls['dataSource'].removeValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].setValidators(Validators.required); );
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['searchSymbol'].removeValidators( this.activityForm.controls['name'].updateValueAndValidity();
Validators.required this.activityForm.controls['quantity'].setValue(1);
); this.activityForm.controls['searchSymbol'].removeValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} else { );
this.activityForm.controls['accountId'].setValidators( this.activityForm.controls['searchSymbol'].updateValueAndValidity();
Validators.required } else {
); this.activityForm.controls['accountId'].setValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['dataSource'].setValidators( );
Validators.required this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['dataSource'].setValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].removeValidators( );
Validators.required this.activityForm.controls['dataSource'].updateValueAndValidity();
); this.activityForm.controls['name'].removeValidators(
this.activityForm.controls['name'].updateValueAndValidity(); Validators.required
this.activityForm.controls['searchSymbol'].setValidators( );
Validators.required this.activityForm.controls['name'].updateValueAndValidity();
); this.activityForm.controls['searchSymbol'].setValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} );
}); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -209,6 +237,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onSubmit() { public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value, accountId: this.activityForm.controls['accountId'].value,
assetClass: this.activityForm.controls['assetClass'].value,
assetSubClass: this.activityForm.controls['assetSubClass'].value,
currency: this.activityForm.controls['currency'].value, currency: this.activityForm.controls['currency'].value,
date: this.activityForm.controls['date'].value, date: this.activityForm.controls['date'].value,
dataSource: this.activityForm.controls['dataSource'].value, dataSource: this.activityForm.controls['dataSource'].value,

View File

@ -134,13 +134,55 @@
> >
</mat-form-field> </mat-form-field>
</div> </div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass"
>{{ assetClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub-Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass"
>{{ assetSubClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-list>
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value">
{{ tag.name }}
</mat-chip>
</mat-chip-list>
</mat-form-field>
</div>
</div> </div>
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="activityForm.controls['currency'].value" [currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0" [value]="total"
></gf-value> ></gf-value>
<div> <div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

View File

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,6 +25,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
FormsModule, FormsModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDatepickerModule, MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

View File

@ -119,7 +119,7 @@
Ghostfolio empowers you to keep track of your wealth. Ghostfolio empowers you to keep track of your wealth.
</p> </p>
<div class="py-2 text-center"> <div class="py-2 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/']"> <a color="primary" href="https://ghostfol.io" i18n mat-flat-button>
Get Started Get Started
</a> </a>
</div> </div>

View File

@ -19,6 +19,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
Export, Export,
Filter,
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
@ -33,7 +34,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { cloneDeep } from 'lodash'; import { cloneDeep, groupBy } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -182,9 +183,42 @@ export class DataService {
); );
} }
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) {
let params = new HttpParams();
if (filters?.length > 0) {
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
if (filtersByAccount) {
params = params.append(
'accounts',
filtersByAccount
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
filtersByTag
.map(({ id }) => {
return id;
})
.join(',')
);
}
}
return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', { return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
params: aParams params
}); });
} }

View File

@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Account, DataSource, Type } from '@prisma/client'; import { Account, DataSource, Type } from '@prisma/client';
import { parse } from 'date-fns'; import { parse } from 'date-fns';
import { isNumber } from 'lodash'; import { isFinite } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -185,7 +185,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.FEE_KEYS) { for (const key of ImportTransactionsService.FEE_KEYS) {
if ((item[key] || item[key] === 0) && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }
@ -208,7 +208,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.QUANTITY_KEYS) { for (const key of ImportTransactionsService.QUANTITY_KEYS) {
if (item[key] && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }
@ -288,7 +288,7 @@ export class ImportTransactionsService {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) { for (const key of ImportTransactionsService.UNIT_PRICE_KEYS) {
if (item[key] && isNumber(item[key])) { if (isFinite(item[key])) {
return item[key]; return item[key];
} }
} }

View File

@ -5,5 +5,6 @@
"types": ["node"], "types": ["node"],
"typeRoots": ["../node_modules/@types"] "typeRoots": ["../node_modules/@types"]
}, },
"files": ["src/main.ts", "src/polyfills.ts"] "files": ["src/main.ts", "src/polyfills.ts"],
"exclude": ["jest.config.ts"]
} }

View File

@ -3,5 +3,6 @@
"include": ["**/*.ts"], "include": ["**/*.ts"],
"compilerOptions": { "compilerOptions": {
"types": ["jest", "node"] "types": ["jest", "node"]
} },
"exclude": ["jest.config.ts"]
} }

View File

@ -6,5 +6,5 @@
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"files": ["src/test-setup.ts"], "files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'common', displayName: 'common',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' }
}, },
@ -8,5 +8,6 @@ module.exports = {
'^.+\\.[tj]sx?$': 'ts-jest' '^.+\\.[tj]sx?$': 'ts-jest'
}, },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/common' coverageDirectory: '../../coverage/libs/common',
preset: '../../jest.preset.ts'
}; };

View File

@ -44,8 +44,12 @@ export const warnColorRgb = {
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND'; export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';

View File

@ -0,0 +1,5 @@
export interface Filter {
id: string;
label?: string;
type: 'account' | 'tag';
}

View File

@ -8,6 +8,7 @@ import {
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import { Coupon } from './coupon.interface'; import { Coupon } from './coupon.interface';
import { Export } from './export.interface'; import { Export } from './export.interface';
import { Filter } from './filter.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioChart } from './portfolio-chart.interface';
import { PortfolioDetails } from './portfolio-details.interface'; import { PortfolioDetails } from './portfolio-details.interface';
@ -38,6 +39,7 @@ export {
AdminMarketDataItem, AdminMarketDataItem,
Coupon, Coupon,
Export, Export,
Filter,
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,

View File

@ -1,3 +1,5 @@
import { Tag } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface'; import { Subscription } from './subscription.interface';
@ -13,4 +15,5 @@ export interface InfoItem {
stripePublicKey?: string; stripePublicKey?: string;
subscriptions: Subscription[]; subscriptions: Subscription[];
systemMessage?: string; systemMessage?: string;
tags: Tag[];
} }

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/api/app/user/interfaces/access.interface'; import { Access } from '@ghostfolio/api/app/user/interfaces/access.interface';
import { Account } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { UserSettings } from './user-settings.interface'; import { UserSettings } from './user-settings.interface';
@ -14,4 +14,5 @@ export interface User {
expiresAt?: Date; expiresAt?: Date;
type: 'Basic' | 'Premium'; type: 'Basic' | 'Premium';
}; };
tags: Tag[];
} }

View File

@ -1,8 +1,9 @@
import { Account, Order, Platform, SymbolProfile } from '@prisma/client'; import { Account, Order, Platform, SymbolProfile, Tag } from '@prisma/client';
type AccountWithPlatform = Account & { Platform?: Platform }; type AccountWithPlatform = Account & { Platform?: Platform };
export type OrderWithAccount = Order & { export type OrderWithAccount = Order & {
Account?: AccountWithPlatform; Account?: AccountWithPlatform;
SymbolProfile?: SymbolProfile; SymbolProfile?: SymbolProfile;
tags?: Tag[];
}; };

View File

@ -5,5 +5,5 @@
"types": [] "types": []
}, },
"include": ["**/*.ts"], "include": ["**/*.ts"],
"exclude": ["**/*.spec.ts", "**/*.test.ts"] "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"]
} }

View File

@ -14,6 +14,7 @@
"**/*.test.js", "**/*.test.js",
"**/*.spec.jsx", "**/*.spec.jsx",
"**/*.test.jsx", "**/*.test.jsx",
"**/*.d.ts" "**/*.d.ts",
"jest.config.ts"
] ]
} }

View File

@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"exclude": ["../**/*.spec.ts", "../**/*.test.ts"], "exclude": ["../**/*.spec.ts", "../**/*.test.ts", "jest.config.ts"],
"include": ["../src/**/*", "*.js"] "include": ["../src/**/*", "*.js"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'ui', displayName: 'ui',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
'jest-preset-angular/build/serializers/no-ng-attributes', 'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot', 'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment' 'jest-preset-angular/build/serializers/html-comment'
] ],
preset: '../../jest.preset.ts'
}; };

View File

@ -0,0 +1,38 @@
<mat-form-field appearance="outline" class="w-100">
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
<mat-chip-list #chipList aria-label="Search keywords">
<mat-chip
*ngFor="let filter of selectedFilters"
class="mx-1 my-0 px-2 py-0"
matChipRemove
[removable]="true"
(removed)="onRemoveFilter(filter)"
>
{{ filter.label | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
#searchInput
name="close-outline"
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[placeholder]="placeholder"
(matChipInputTokenEnd)="onAddFilter($event)"
/>
</mat-chip-list>
<mat-autocomplete
#autocomplete="matAutocomplete"
(optionSelected)="onSelectFilter($event)"
>
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter.label | gfSymbol }}
</mat-option>
</mat-autocomplete>
<mat-spinner
matSuffix
[diameter]="20"
[ngClass]="{ 'd-none': !isLoading }"
></mat-spinner>
</mat-form-field>

View File

@ -0,0 +1,36 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
.mat-spinner {
circle {
stroke: rgba(var(--dark-dividers));
}
}
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
::ng-deep {
.mat-spinner {
circle {
stroke: rgba(var(--light-dividers));
}
}
}
}

View File

@ -0,0 +1,126 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import { FormControl } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { Filter } from '@ghostfolio/common/interfaces';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activities-filter',
styleUrls: ['./activities-filter.component.scss'],
templateUrl: './activities-filter.component.html'
})
export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
@Input() allFilters: Filter[];
@Input() isLoading: boolean;
@Input() placeholder: string;
@Output() valueChanged = new EventEmitter<Filter[]>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
public filters: Observable<Filter[]> = this.filters$.asObservable();
public searchControl = new FormControl();
public selectedFilters: Filter[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA];
private unsubscribeSubject = new Subject<void>();
public constructor() {
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((currentFilter: string) => {
if (currentFilter) {
this.filters$.next(
this.allFilters
.filter((filter) => {
// Filter selected filters
return !this.selectedFilters.some((selectedFilter) => {
return selectedFilter.id === filter.id;
});
})
.filter((filter) => {
return filter.label
.toLowerCase()
.startsWith(currentFilter?.toLowerCase());
})
.sort((a, b) => a.label.localeCompare(b.label))
);
}
});
}
public ngOnChanges(changes: SimpleChanges) {
if (changes.allFilters?.currentValue) {
this.updateFilter();
}
}
public onAddFilter({ input, value }: MatChipInputEvent): void {
if (value?.trim()) {
this.updateFilter();
}
// Reset the input value
if (input) {
input.value = '';
}
this.searchControl.setValue(null);
}
public onRemoveFilter(aFilter: Filter): void {
this.selectedFilters = this.selectedFilters.filter((filter) => {
return filter.id !== aFilter.id;
});
this.updateFilter();
}
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
this.selectedFilters.push(event.option.value);
this.updateFilter();
this.searchInput.nativeElement.value = '';
this.searchControl.setValue(null);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private updateFilter() {
this.filters$.next(
this.allFilters
.filter((filter) => {
// Filter selected filters
return !this.selectedFilters.some((selectedFilter) => {
return selectedFilter.id === filter.id;
});
})
.sort((a, b) => a.label.localeCompare(b.label))
);
// Emit an array with a new reference
this.valueChanged.emit([...this.selectedFilters]);
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { ActivitiesFilterComponent } from './activities-filter.component';
@NgModule({
declarations: [ActivitiesFilterComponent],
exports: [ActivitiesFilterComponent],
imports: [
CommonModule,
GfSymbolModule,
MatAutocompleteModule,
MatChipsModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfActivitiesFilterModule {}

View File

@ -1,40 +1,10 @@
<mat-form-field <gf-activities-filter
appearance="outline" [allFilters]="allFilters"
class="w-100" [isLoading]="isLoading"
[ngClass]="{ 'd-none': !hasPermissionToFilter }" [ngClass]="{ 'd-none': !hasPermissionToFilter }"
> [placeholder]="placeholder"
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon> (valueChanged)="filters$.next($event)"
<mat-chip-list #chipList aria-label="Search keywords"> ></gf-activities-filter>
<mat-chip
*ngFor="let searchKeyword of searchKeywords"
class="mx-1 my-0 px-2 py-0"
matChipRemove
[removable]="true"
(removed)="removeKeyword(searchKeyword)"
>
{{ searchKeyword | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
#searchInput
name="close-outline"
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[placeholder]="placeholder"
(matChipInputTokenEnd)="addKeyword($event)"
/>
</mat-chip-list>
<mat-autocomplete
#autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)"
>
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter | gfSymbol }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<div class="activities"> <div class="activities">
<table <table

View File

@ -3,17 +3,6 @@
:host { :host {
display: block; display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
.activities { .activities {
overflow-x: auto; overflow-x: auto;
@ -68,10 +57,6 @@
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
.mat-table { .mat-table {
td { td {
&.mat-footer-cell { &.mat-footer-cell {

View File

@ -1,8 +1,6 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
@ -11,24 +9,18 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, format, isAfter } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...'; const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
const SEARCH_STRING_SEPARATOR = ','; const SEARCH_STRING_SEPARATOR = ',';
@ -59,16 +51,14 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() exportDrafts = new EventEmitter<string[]>(); @Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public allFilters: Filter[];
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource(); public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns = []; public displayedColumns = [];
public endOfToday = endOfToday(); public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]); public filters$ = new Subject<Filter[]>();
public filters: Observable<string[]> = this.filters$.asObservable();
public hasDrafts = false; public hasDrafts = false;
public isAfter = isAfter; public isAfter = isAfter;
public isLoading = true; public isLoading = true;
@ -77,60 +67,19 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public searchControl = new FormControl(); public searchControl = new FormControl();
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA];
public totalFees: number; public totalFees: number;
public totalValue: number; public totalValue: number;
private allFilters: string[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) { public constructor(private router: Router) {
this.searchControl.valueChanges this.filters$
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.subscribe((keyword) => { .subscribe((filters) => {
if (keyword) { this.updateFilters(filters);
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
}); });
} }
public addKeyword({ input, value }: MatChipInputEvent): void {
if (value?.trim()) {
this.searchKeywords.push(value.trim());
this.updateFilter();
}
// Reset the input value
if (input) {
input.value = '';
}
this.searchControl.setValue(null);
}
public removeKeyword(keyword: string): void {
const index = this.searchKeywords.indexOf(keyword);
if (index >= 0) {
this.searchKeywords.splice(index, 1);
this.updateFilter();
}
}
public keywordSelected(event: MatAutocompleteSelectedEvent): void {
this.searchKeywords.push(event.option.viewValue);
this.updateFilter();
this.searchInput.nativeElement.value = '';
this.searchControl.setValue(null);
}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'count', 'count',
@ -153,11 +102,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
}); });
} }
this.isLoading = true;
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) { if (this.activities) {
this.allFilters = this.getSearchableFieldValues(this.activities).map(
(label) => {
return { label, id: label, type: 'tag' };
}
);
this.dataSource = new MatTableDataSource(this.activities); this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => { this.dataSource.filterPredicate = (data, filter) => {
const dataString = this.getFilterableValues(data) const dataString = this.getFilterableValues(data)
@ -171,8 +124,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
return contains; return contains;
}; };
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.updateFilter();
this.isLoading = false; this.updateFilters();
} }
} }
@ -235,28 +188,24 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private updateFilter() { private getFilterableValues(
this.dataSource.filter = this.searchKeywords.join(SEARCH_STRING_SEPARATOR); activity: OrderWithAccount,
const lowercaseSearchKeywords = this.searchKeywords.map((keyword) => fieldValues: Set<string> = new Set<string>()
keyword.trim().toLowerCase() ): string[] {
); fieldValues.add(activity.Account?.name);
fieldValues.add(activity.Account?.Platform?.name);
fieldValues.add(activity.SymbolProfile.currency);
this.placeholder = if (!isUUID(activity.SymbolProfile.symbol)) {
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : ''; fieldValues.add(activity.SymbolProfile.symbol);
}
this.allFilters = this.getSearchableFieldValues(this.activities).filter( fieldValues.add(activity.type);
(item) => { fieldValues.add(format(activity.date, 'yyyy'));
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
}
);
this.filters$.next(this.allFilters); return [...fieldValues].filter((item) => {
return item !== undefined;
this.hasDrafts = this.dataSource.data.some((activity) => {
return activity.isDraft === true;
}); });
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
} }
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] { private getSearchableFieldValues(activities: OrderWithAccount[]): string[] {
@ -287,26 +236,6 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
}); });
} }
private getFilterableValues(
activity: OrderWithAccount,
fieldValues: Set<string> = new Set<string>()
): string[] {
fieldValues.add(activity.Account?.name);
fieldValues.add(activity.Account?.Platform?.name);
fieldValues.add(activity.SymbolProfile.currency);
if (!isUUID(activity.SymbolProfile.symbol)) {
fieldValues.add(activity.SymbolProfile.symbol);
}
fieldValues.add(activity.type);
fieldValues.add(format(activity.date, 'yyyy'));
return [...fieldValues].filter((item) => {
return item !== undefined;
});
}
private getTotalFees() { private getTotalFees() {
let totalFees = new Big(0); let totalFees = new Big(0);
@ -338,4 +267,32 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
return totalValue.toNumber(); return totalValue.toNumber();
} }
private updateFilters(filters: Filter[] = []) {
this.isLoading = true;
this.dataSource.filter = filters
.map((filter) => {
return filter.label;
})
.join(SEARCH_STRING_SEPARATOR);
const lowercaseSearchKeywords = filters.map((filter) => {
return filter.label.trim().toLowerCase();
});
this.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
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

@ -1,16 +1,13 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,19 +19,16 @@ import { ActivitiesTableComponent } from './activities-table.component';
exports: [ActivitiesTableComponent], exports: [ActivitiesTableComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfNoTransactionsInfoModule, GfNoTransactionsInfoModule,
GfSymbolIconModule, GfSymbolIconModule,
GfSymbolModule, GfSymbolModule,
GfValueModule, GfValueModule,
MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -5,9 +5,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
@ -39,7 +41,11 @@ export class FireCalculatorComponent
@Input() currency: string; @Input() currency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() fireWealth: number; @Input() fireWealth: number;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() locale: string; @Input() locale: string;
@Input() savingsRate = 0;
@Output() savingsRateChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -71,46 +77,82 @@ export class FireCalculatorComponent
Tooltip Tooltip
); );
this.calculatorForm.setValue({ this.calculatorForm.setValue(
annualInterestRate: 5, {
paymentPerPeriod: 500, annualInterestRate: 5,
principalInvestmentAmount: 0, paymentPerPeriod: this.savingsRate,
time: 10 principalInvestmentAmount: 0,
}); time: 10
},
{
emitEvent: false
}
);
this.calculatorForm.valueChanges this.calculatorForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
this.calculatorForm
.get('paymentPerPeriod')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((savingsRate) => {
this.savingsRateChanged.emit(savingsRate);
});
} }
public ngAfterViewInit() { public ngAfterViewInit() {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => { setTimeout(() => {
// Wait for the chartCanvas // Wait for the chartCanvas
this.calculatorForm.patchValue({ this.calculatorForm.patchValue(
principalInvestmentAmount: this.fireWealth {
}); principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
},
{
emitEvent: false
}
);
this.calculatorForm.get('principalInvestmentAmount').disable(); this.calculatorForm.get('principalInvestmentAmount').disable();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
} else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
}
} }
public ngOnChanges() { public ngOnChanges() {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => { setTimeout(() => {
// Wait for the chartCanvas // Wait for the chartCanvas
this.calculatorForm.patchValue({ this.calculatorForm.patchValue(
principalInvestmentAmount: this.fireWealth {
}); principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
},
{
emitEvent: false
}
);
this.calculatorForm.get('principalInvestmentAmount').disable(); this.calculatorForm.get('principalInvestmentAmount').disable();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
} else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
}
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -152,8 +194,10 @@ export class FireCalculatorComponent
0 0
); );
return `Total Amount: ${new Intl.NumberFormat(this.locale, { return `Total: ${new Intl.NumberFormat(this.locale, {
currency: this.currency, currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency'
}).format(totalAmount)}`; }).format(totalAmount)}`;
@ -168,6 +212,8 @@ export class FireCalculatorComponent
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += new Intl.NumberFormat(this.locale, { label += new Intl.NumberFormat(this.locale, {
currency: this.currency, currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency'
}).format(context.parsed.y); }).format(context.parsed.y);

View File

@ -15,7 +15,7 @@ import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Tooltip } from 'chart.js'; import { ChartConfiguration, Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js'; import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js'; import { DoughnutController } from 'chart.js';
@ -192,13 +192,8 @@ export class PortfolioProportionChartComponent
// Reuse color // Reuse color
item.color = this.colorMap[symbol]; item.color = this.colorMap[symbol];
} else { } else {
const color = item.color =
this.getColorPalette()[index % this.getColorPalette().length]; this.getColorPalette()[index % this.getColorPalette().length];
// Store color for reuse
this.colorMap[symbol] = color;
item.color = color;
} }
}); });
@ -220,7 +215,7 @@ export class PortfolioProportionChartComponent
}); });
}); });
const datasets = [ const datasets: ChartConfiguration['data']['datasets'] = [
{ {
backgroundColor: chartDataSorted.map(([, item]) => { backgroundColor: chartDataSorted.map(([, item]) => {
return item.color; return item.color;
@ -252,7 +247,7 @@ export class PortfolioProportionChartComponent
datasets[0].data[0] = Number.MAX_SAFE_INTEGER; datasets[0].data[0] = Number.MAX_SAFE_INTEGER;
} }
const data = { const data: ChartConfiguration['data'] = {
datasets, datasets,
labels labels
}; };
@ -260,11 +255,14 @@ export class PortfolioProportionChartComponent
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration(data);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
data, data,
options: <unknown>{ options: <unknown>{
animation: false,
cutout: '70%', cutout: '70%',
layout: { layout: {
padding: this.showLabels === true ? 100 : 0 padding: this.showLabels === true ? 100 : 0
@ -306,46 +304,7 @@ export class PortfolioProportionChartComponent
} }
}, },
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: this.getTooltipPluginConfiguration(data)
callbacks: {
label: (context) => {
const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ??
0) + context.dataIndex;
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
if (symbol === this.OTHER_KEY) {
symbol = 'Other';
} else if (symbol === UNKNOWN_KEY) {
symbol = 'Unknown';
}
const name = this.positions[<string>symbol]?.name;
let sum = 0;
context.dataset.data.map((item) => {
sum += item;
});
const percentage = (context.parsed * 100) / sum;
if (<number>context.raw === Number.MAX_SAFE_INTEGER) {
return 'No data available';
} else if (this.isInPercent) {
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else {
const value = <number>context.raw;
return [
`${name ?? symbol}`,
`${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency} (${percentage.toFixed(2)}%)`
];
}
}
}
}
} }
}, },
plugins: [ChartDataLabels], plugins: [ChartDataLabels],
@ -377,4 +336,47 @@ export class PortfolioProportionChartComponent
'#cc5de8' // grape 5 '#cc5de8' // grape 5
]; ];
} }
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) {
return {
callbacks: {
label: (context) => {
const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) +
context.dataIndex;
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
if (symbol === this.OTHER_KEY) {
symbol = 'Other';
} else if (symbol === UNKNOWN_KEY) {
symbol = 'No data available';
}
const name = this.positions[<string>symbol]?.name;
let sum = 0;
for (const item of context.dataset.data) {
sum += item;
}
const percentage = (context.parsed * 100) / sum;
if (<number>context.raw === Number.MAX_SAFE_INTEGER) {
return 'No data available';
} else if (this.isInPercent) {
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else {
const value = <number>context.raw;
return [
`${name ?? symbol}`,
`${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency} (${percentage.toFixed(2)}%)`
];
}
}
}
};
}
} }

View File

@ -10,14 +10,14 @@
</ng-container> </ng-container>
<div <div
*ngIf="isPercent" *ngIf="isPercent"
class="mb-0" class="mb-0 value"
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }" [ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
> >
{{ formattedValue }}% {{ formattedValue }}%
</div> </div>
<div <div
*ngIf="!isPercent" *ngIf="!isPercent"
class="mb-0" class="mb-0 value"
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }" [ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
> >
<ng-container *ngIf="value === null"> <ng-container *ngIf="value === null">
@ -36,7 +36,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="isString"> <ng-container *ngIf="isString">
<div <div
class="mb-0 text-truncate" class="mb-0 text-truncate value"
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }" [ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
> >
{{ formattedValue | titlecase }} {{ formattedValue | titlecase }}
@ -45,7 +45,8 @@
</div> </div>
<ng-container *ngIf="label"> <ng-container *ngIf="label">
<div *ngIf="size === 'large'"> <div *ngIf="size === 'large'">
{{ label }} <span class="h6">{{ label }}</span>
<span *ngIf="subLabel" class="text-muted"> {{ subLabel }}</span>
</div> </div>
<small *ngIf="size !== 'large'"> <small *ngIf="size !== 'large'">
{{ label }} {{ label }}

View File

@ -2,4 +2,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
.h2 {
line-height: 1;
}
} }

View File

@ -25,6 +25,7 @@ export class ValueComponent implements OnChanges {
@Input() position = ''; @Input() position = '';
@Input() precision: number | undefined; @Input() precision: number | undefined;
@Input() size: 'large' | 'medium' | 'small' = 'small'; @Input() size: 'large' | 'medium' | 'small' = 'small';
@Input() subLabel = '';
@Input() value: number | string = ''; @Input() value: number | string = '';
public absoluteValue = 0; public absoluteValue = 0;

View File

@ -14,7 +14,8 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.test.ts", "**/*.test.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/*.stories.js" "**/*.stories.js",
"jest.config.ts"
], ],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -6,5 +6,5 @@
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"files": ["src/test-setup.ts"], "files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -15,8 +15,9 @@
"npmScope": "ghostfolio", "npmScope": "ghostfolio",
"tasksRunnerOptions": { "tasksRunnerOptions": {
"default": { "default": {
"runner": "@nrwl/workspace/tasks-runners/default", "runner": "@nrwl/nx-cloud",
"options": { "options": {
"accessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
"cacheableOperations": [ "cacheableOperations": [
"build", "build",
"lint", "lint",

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.139.0", "version": "1.146.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -50,18 +50,19 @@
"workspace-generator": "nx workspace-generator" "workspace-generator": "nx workspace-generator"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "13.2.2", "@angular/animations": "13.3.6",
"@angular/cdk": "13.2.2", "@angular/cdk": "13.3.6",
"@angular/common": "13.2.2", "@angular/common": "13.3.6",
"@angular/compiler": "13.2.2", "@angular/compiler": "13.3.6",
"@angular/core": "13.2.2", "@angular/core": "13.3.6",
"@angular/forms": "13.2.2", "@angular/forms": "13.3.6",
"@angular/material": "13.2.2", "@angular/material": "13.3.6",
"@angular/platform-browser": "13.2.2", "@angular/platform-browser": "13.3.6",
"@angular/platform-browser-dynamic": "13.2.2", "@angular/platform-browser-dynamic": "13.3.6",
"@angular/router": "13.2.2", "@angular/router": "13.3.6",
"@codewithdan/observable-store": "2.2.11", "@codewithdan/observable-store": "2.2.11",
"@dinero.js/currencies": "2.0.0-alpha.8", "@dinero.js/currencies": "2.0.0-alpha.8",
"@nestjs/bull": "0.5.5",
"@nestjs/common": "8.2.3", "@nestjs/common": "8.2.3",
"@nestjs/config": "1.1.3", "@nestjs/config": "1.1.3",
"@nestjs/core": "8.2.3", "@nestjs/core": "8.2.3",
@ -70,8 +71,8 @@
"@nestjs/platform-express": "8.2.3", "@nestjs/platform-express": "8.2.3",
"@nestjs/schedule": "1.0.2", "@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2", "@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.8.5", "@nrwl/angular": "14.1.4",
"@prisma/client": "3.11.1", "@prisma/client": "3.12.0",
"@simplewebauthn/browser": "4.1.0", "@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0", "@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0", "@simplewebauthn/typescript-types": "4.0.0",
@ -82,6 +83,7 @@
"bent": "7.3.12", "bent": "7.3.12",
"big.js": "6.1.1", "big.js": "6.1.1",
"bootstrap": "4.6.0", "bootstrap": "4.6.0",
"bull": "4.8.2",
"cache-manager": "3.4.3", "cache-manager": "3.4.3",
"cache-manager-redis-store": "2.0.0", "cache-manager-redis-store": "2.0.0",
"chart.js": "3.7.0", "chart.js": "3.7.0",
@ -109,7 +111,7 @@
"passport": "0.4.1", "passport": "0.4.1",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "3.11.1", "prisma": "3.12.0",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"round-to": "5.0.0", "round-to": "5.0.0",
"rxjs": "7.4.0", "rxjs": "7.4.0",
@ -118,38 +120,40 @@
"tslib": "2.0.0", "tslib": "2.0.0",
"twitter-api-v2": "1.10.3", "twitter-api-v2": "1.10.3",
"uuid": "8.3.2", "uuid": "8.3.2",
"yahoo-finance2": "2.3.0", "yahoo-finance2": "2.3.2",
"zone.js": "0.11.4" "zone.js": "0.11.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "13.2.3", "@angular-devkit/build-angular": "13.3.5",
"@angular-eslint/eslint-plugin": "13.0.1", "@angular-eslint/eslint-plugin": "13.0.1",
"@angular-eslint/eslint-plugin-template": "13.0.1", "@angular-eslint/eslint-plugin-template": "13.0.1",
"@angular-eslint/template-parser": "13.0.1", "@angular-eslint/template-parser": "13.0.1",
"@angular/cli": "13.2.3", "@angular/cli": "13.3.5",
"@angular/compiler-cli": "13.2.2", "@angular/compiler-cli": "13.3.6",
"@angular/language-service": "13.2.2", "@angular/language-service": "13.3.6",
"@angular/localize": "13.2.2", "@angular/localize": "13.3.6",
"@nestjs/schematics": "8.0.5", "@nestjs/schematics": "8.0.5",
"@nestjs/testing": "8.2.3", "@nestjs/testing": "8.2.3",
"@nrwl/cli": "13.8.5", "@nrwl/cli": "14.1.4",
"@nrwl/cypress": "13.8.5", "@nrwl/cypress": "14.1.4",
"@nrwl/eslint-plugin-nx": "13.8.5", "@nrwl/eslint-plugin-nx": "14.1.4",
"@nrwl/jest": "13.8.5", "@nrwl/jest": "14.1.4",
"@nrwl/nest": "13.8.5", "@nrwl/nest": "14.1.4",
"@nrwl/node": "13.8.5", "@nrwl/node": "14.1.4",
"@nrwl/storybook": "13.8.5", "@nrwl/nx-cloud": "14.0.3",
"@nrwl/tao": "13.8.5", "@nrwl/storybook": "14.1.4",
"@nrwl/workspace": "13.8.5", "@nrwl/workspace": "14.1.4",
"@storybook/addon-essentials": "6.4.18", "@storybook/addon-essentials": "6.4.22",
"@storybook/angular": "6.4.18", "@storybook/angular": "6.4.22",
"@storybook/builder-webpack5": "6.4.18", "@storybook/builder-webpack5": "6.4.22",
"@storybook/manager-webpack5": "6.4.18", "@storybook/core-server": "6.4.22",
"@storybook/manager-webpack5": "6.4.22",
"@types/big.js": "6.1.2", "@types/big.js": "6.1.2",
"@types/bull": "3.15.8",
"@types/cache-manager": "3.4.2", "@types/cache-manager": "3.4.2",
"@types/color": "3.0.2", "@types/color": "3.0.2",
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",
"@types/jest": "27.0.2", "@types/jest": "27.4.1",
"@types/lodash": "4.14.174", "@types/lodash": "4.14.174",
"@types/node": "14.14.33", "@types/node": "14.14.33",
"@types/passport-google-oauth20": "2.0.11", "@types/passport-google-oauth20": "2.0.11",
@ -165,14 +169,15 @@
"import-sort-cli": "6.0.0", "import-sort-cli": "6.0.0",
"import-sort-parser-typescript": "6.0.0", "import-sort-parser-typescript": "6.0.0",
"import-sort-style-module": "6.0.0", "import-sort-style-module": "6.0.0",
"jest": "27.2.3", "jest": "27.5.1",
"jest-preset-angular": "11.1.1", "jest-preset-angular": "11.1.1",
"nx": "14.1.4",
"prettier": "2.5.1", "prettier": "2.5.1",
"replace-in-file": "6.2.0", "replace-in-file": "6.2.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"ts-jest": "27.0.5", "ts-jest": "27.1.4",
"ts-node": "9.1.1", "ts-node": "9.1.1",
"typescript": "4.5.5" "typescript": "4.6.4"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=14"

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "SymbolProfileOverrides" (
"assetClass" "AssetClass",
"assetSubClass" "AssetSubClass",
"countries" JSONB,
"name" TEXT,
"sectors" JSONB,
"symbolProfileId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SymbolProfileOverrides_pkey" PRIMARY KEY ("symbolProfileId")
);
-- AddForeignKey
ALTER TABLE "SymbolProfileOverrides" ADD CONSTRAINT "SymbolProfileOverrides_symbolProfileId_fkey" FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'COMMODITY';

View File

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "Access" DROP CONSTRAINT "Access_pkey",
ADD CONSTRAINT "Access_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "MarketData" ADD CONSTRAINT "MarketData_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "Order" DROP CONSTRAINT "Order_pkey",
ADD CONSTRAINT "Order_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_pkey",
ADD CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id");

View File

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_OrderToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "_OrderToTag_AB_unique" ON "_OrderToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_OrderToTag_B_index" ON "_OrderToTag"("B");
-- AddForeignKey
ALTER TABLE "_OrderToTag" ADD FOREIGN KEY ("A") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_OrderToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetClass" ADD VALUE 'REAL_ESTATE';

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'PRECIOUS_METAL';

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AssetSubClass" ADD VALUE 'PRIVATE_EQUITY';

View File

@ -13,12 +13,10 @@ model Access {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
GranteeUser User? @relation(fields: [granteeUserId], name: "accessGet", references: [id]) GranteeUser User? @relation(fields: [granteeUserId], name: "accessGet", references: [id])
granteeUserId String? granteeUserId String?
id String @default(uuid()) id String @id @default(uuid())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
User User @relation(fields: [userId], name: "accessGive", references: [id]) User User @relation(fields: [userId], name: "accessGive", references: [id])
userId String userId String
@@id([id, userId])
} }
model Account { model Account {
@ -61,7 +59,7 @@ model MarketData {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
dataSource DataSource dataSource DataSource
date DateTime date DateTime
id String @default(uuid()) id String @id @default(uuid())
symbol String symbol String
marketPrice Float marketPrice Float
@ -76,18 +74,17 @@ model Order {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
date DateTime date DateTime
fee Float fee Float
id String @default(uuid()) id String @id @default(uuid())
isDraft Boolean @default(false) isDraft Boolean @default(false)
quantity Float quantity Float
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id]) SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String symbolProfileId String
tags Tag[]
type Type type Type
unitPrice Float unitPrice Float
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
userId String userId String
@@id([id, userId])
} }
model Platform { model Platform {
@ -112,34 +109,50 @@ model Settings {
} }
model SymbolProfile { model SymbolProfile {
assetClass AssetClass? assetClass AssetClass?
assetSubClass AssetSubClass? assetSubClass AssetSubClass?
countries Json? countries Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency String currency String
dataSource DataSource dataSource DataSource
id String @id @default(uuid()) id String @id @default(uuid())
name String? name String?
Order Order[] Order Order[]
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
scraperConfiguration Json? scraperConfiguration Json?
sectors Json? sectors Json?
symbol String symbol String
symbolMapping Json? symbolMapping Json?
url String? SymbolProfileOverrides SymbolProfileOverrides?
url String?
@@unique([dataSource, symbol]) @@unique([dataSource, symbol])
} }
model SymbolProfileOverrides {
assetClass AssetClass?
assetSubClass AssetSubClass?
countries Json?
name String?
sectors Json?
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String @id
updatedAt DateTime @updatedAt
}
model Subscription { model Subscription {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime
id String @default(uuid()) id String @id @default(uuid())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
userId String userId String
}
@@id([id, userId]) model Tag {
id String @id @default(uuid())
name String @unique
orders Order[]
} }
model User { model User {
@ -172,13 +185,17 @@ enum AssetClass {
COMMODITY COMMODITY
EQUITY EQUITY
FIXED_INCOME FIXED_INCOME
REAL_ESTATE
} }
enum AssetSubClass { enum AssetSubClass {
BOND BOND
COMMODITY
CRYPTOCURRENCY CRYPTOCURRENCY
ETF ETF
MUTUALFUND MUTUALFUND
PRECIOUS_METAL
PRIVATE_EQUITY
STOCK STOCK
} }

View File

@ -2,3 +2,4 @@ Date,Code,Currency,Price,Quantity,Action,Fee
17/11/2021,MSFT,USD,0.62,5,dividend,0.00 17/11/2021,MSFT,USD,0.62,5,dividend,0.00
16/09/2021,MSFT,USD,298.580,5,buy,19.00 16/09/2021,MSFT,USD,298.580,5,buy,19.00
01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00 01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00
06/06/2050,MSFT,USD,0.00,0,buy,0.00

1 Date Code Currency Price Quantity Action Fee
2 17/11/2021 MSFT USD 0.62 5 dividend 0.00
3 16/09/2021 MSFT USD 298.580 5 buy 19.00
4 01/01/2022 Penthouse Apartment USD 500000.0 1 item 0.00
5 06/06/2050 MSFT USD 0.00 0 buy 0.00

View File

@ -5,34 +5,43 @@
}, },
"activities": [ "activities": [
{ {
"accountId": null, "fee": 0,
"date": "2021-12-31T23:00:00.000Z", "quantity": 0,
"type": "BUY",
"unitPrice": 0,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z",
"symbol": "MSFT"
},
{
"fee": 0, "fee": 0,
"quantity": 1, "quantity": 1,
"type": "ITEM", "type": "ITEM",
"unitPrice": 500000, "unitPrice": 500000,
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z",
"symbol": "Penthouse Apartment" "symbol": "Penthouse Apartment"
}, },
{ {
"date": "2021-11-16T23:00:00.000Z",
"fee": 0, "fee": 0,
"quantity": 5, "quantity": 5,
"type": "DIVIDEND", "type": "DIVIDEND",
"unitPrice": 0.62, "unitPrice": 0.62,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
"date": "2021-09-15T22:00:00.000Z",
"fee": 19, "fee": 19,
"quantity": 5, "quantity": 5,
"type": "BUY", "type": "BUY",
"unitPrice": 298.58, "unitPrice": 298.58,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
} }
] ]

3302
yarn.lock

File diff suppressed because it is too large Load Diff