Compare commits

..

31 Commits

Author SHA1 Message Date
a3a9957196 Release 1.27.0 (#223) 2021-07-18 20:05:46 +02:00
9072cbdba1 Feature/add no transactions info on zen page (#222)
* Add no transactions info to zen page

* Update changelog
2021-07-18 17:34:28 +02:00
120b691336 Bugfix/fix url to fear and greed index (#221)
* Fix url

* Update changelog
2021-07-18 09:33:23 +02:00
bd4ad76953 Feature/remove pause in onboarding (#220)
* Improve onboarding
  * Remove pause
  * Add icon

* Update changelog
2021-07-18 08:19:05 +02:00
94d56f553f Bugfix/fix chart on landing page (#219)
* Fix chart on landing page

* Update changelog
2021-07-17 20:48:08 +02:00
ecdd325228 Release 1.26.0 (#217) 2021-07-17 11:06:14 +02:00
51fbc538ca Feature/set public stripe key dynamically (#216)
* Set public Stripe key dynamically

* Update changelog
2021-07-17 11:04:43 +02:00
39a76f7f40 Feature/add robots.txt (#215)
* Add robots.txt

* Update changelog
2021-07-16 21:32:02 +02:00
e4d325daab Feature/various style improvements (#214)
* Improve styles

* Update changelog
2021-07-15 18:10:18 +02:00
b765df65d6 Improve wording (#213) 2021-07-14 20:54:23 +02:00
c7b7efae3b Feature/import transactions (#212)
* Implement import transactions functionality

* Update changelog
2021-07-14 20:54:05 +02:00
be5b58f49a Bugfix/fix warn color (#211)
* Fix warn color

* Update changelog
2021-07-13 20:29:22 +02:00
91c748c7ad Release 1.25.0 (#210) 2021-07-11 17:21:24 +02:00
ecfe694f0b Feature/export transactions (#209)
* Export functionality for transactions

* Update changelog
2021-07-11 17:05:58 +02:00
1491bf7f76 Update changelog (#208) 2021-07-11 10:37:02 +02:00
b3b9a051c3 Shorten slogan (#207) 2021-07-11 10:31:36 +02:00
bf1146bfd6 Feature/change slogan to wealth management (#206)
* Harmonize slogans to "Open Source Wealth Management Software"

* Update changelog
2021-07-10 19:20:02 +02:00
0774ca91a1 Improve settings selectors layout (#205) 2021-07-10 18:17:17 +02:00
f403807f2d Bugfix/fix average buy price calculation (#204)
* Fix average buy price calculation

* Update changelog
2021-07-10 18:16:46 +02:00
f22991b090 Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis

* Update changelog
2021-07-10 14:57:03 +02:00
1135a5b335 Fix rendering of currency and platform in dialogs and clean up observables (#202) 2021-07-08 21:28:28 +02:00
d9ea255c17 Release 1.24.0 (#201) 2021-07-07 21:45:57 +02:00
2c19d8c8e7 Feature/add balance to account (#193)
* Add balance attribute and calculate total balance

* Update changelog
2021-07-07 21:23:36 +02:00
db090229ce Feature/add total value in the create or edit transaction dialog (#192)
* Display total value

* Update changelog
2021-07-07 21:14:01 +02:00
fbe590ddb9 Feature/upgrade angular material css vars to 2.0.0 (#200)
* Upgrade angular-material-css-vars

* Update changelog
2021-07-05 21:53:30 +02:00
0d65136a9e Revert "Remove unneeded dependencies (#197)" (#199)
This reverts commit a062a3cee4.
2021-07-05 20:35:10 +02:00
dea87cc3cf Improve README.md (#198) 2021-07-04 22:19:09 +02:00
a062a3cee4 Remove unneeded dependencies (#197) 2021-07-04 22:11:47 +02:00
5b1b207a6f Feature/upgrade angular dependencies to version 12.0.x (#196)
* Update angular dependencies to version 12.0.X

* Update changelog
2021-07-04 21:55:25 +02:00
63cc7b2871 Feature/upgrade nestjs dependencies (#195)
* Upgrade @nestjs dependencies

* Update changelog
2021-07-04 21:45:53 +02:00
3986e8f879 Upgrade Nx to version 12.5.4 (#194) 2021-07-04 21:31:15 +02:00
116 changed files with 2363 additions and 1302 deletions

View File

@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed

View File

@ -1,13 +1,22 @@
<div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Portfolio Tracker</strong>
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
@ -15,7 +24,13 @@
</p>
</div>
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio?
@ -79,8 +94,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development

View File

@ -103,6 +103,11 @@
"input": "",
"output": "./"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",

View File

@ -11,5 +11,6 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000
testTimeout: 10000,
testEnvironment: 'node'
};

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,

View File

@ -1,12 +1,15 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
@ -73,6 +76,27 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;

View File

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;

View File

@ -23,6 +23,8 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
@ -41,6 +43,8 @@ import { UserModule } from './user/user.module';
CacheModule,
ConfigModule.forRoot(),
ExperimentalModule,
ExportModule,
ImportModule,
InfoModule,
OrderModule,
PortfolioModule,

View File

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +15,7 @@ import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
@ -52,6 +54,7 @@ export class ExperimentalService {
});
const portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -0,0 +1,32 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -0,0 +1,31 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -0,0 +1,7 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

View File

@ -0,0 +1,50 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

View File

@ -0,0 +1,43 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

View File

@ -20,6 +20,7 @@ export class InfoService {
) {}
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
@ -27,6 +28,10 @@ export class InfoService {
const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
@ -37,9 +42,12 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
return {
...info,
globalPermissions,
platforms,
currencies: Object.values(Currency),

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()

View File

@ -142,10 +142,11 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -221,6 +222,7 @@ export class PortfolioController {
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
@ -238,10 +240,11 @@ export class PortfolioController {
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -306,10 +309,11 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id

View File

@ -1,3 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
@ -11,10 +16,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -22,6 +23,7 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AccountService,
AlphaVantageService,
CacheService,
ConfigurationService,

View File

@ -1,3 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +18,7 @@ import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import {
add,
addMonths,
endOfToday,
format,
getDate,
@ -30,9 +35,6 @@ import {
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import {
HistoricalDataItem,
PortfolioPositionDetail
@ -41,6 +43,7 @@ import {
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
@ -68,6 +71,7 @@ export class PortfolioService {
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -84,6 +88,7 @@ export class PortfolioService {
});
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -192,12 +197,17 @@ export class PortfolioService {
impersonationUserId || this.request.user.id
);
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
cash: balance,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
@ -218,19 +228,18 @@ export class PortfolioService {
impersonationUserId || this.request.user.id
);
const positions = portfolio.getPositions(new Date())[aSymbol];
const position = portfolio.getPositions(new Date())[aSymbol];
if (positions) {
let {
if (position) {
const {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
} = position;
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical(
@ -258,13 +267,14 @@ export class PortfolioService {
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of month
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
.positions[aSymbol];
// Get snapshot of first day of next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 1)
)?.[0]?.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot?.averagePrice;
currentAveragePrice = snapshot.averagePrice;
}
}
@ -343,13 +353,13 @@ export class PortfolioService {
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,

View File

@ -1,3 +1,4 @@
export const environment = {
production: true
production: true,
version: `v${require('../../../../package.json').version}`
};

View File

@ -1,3 +1,4 @@
export const environment = {
production: false
production: false,
version: 'dev'
};

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
@ -16,6 +17,16 @@ import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
@ -81,12 +92,14 @@ const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let accountService: AccountService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService(
null,
null,
@ -101,6 +114,7 @@ describe('Portfolio', () => {
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
accountService,
dataProviderService,
exchangeRateDataService,
rulesService
@ -110,7 +124,9 @@ describe('Portfolio', () => {
Account: [
{
accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
@ -145,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return zero performance for 1d', async () => {

View File

@ -1,4 +1,6 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
@ -11,7 +13,7 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
@ -34,7 +36,7 @@ import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { IOrder, MarketState, Type } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
@ -54,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings;
public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
@ -232,10 +235,14 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const cashDetails = await this.accountService.getCashDetails(
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {};
@ -372,6 +379,12 @@ export class Portfolio implements PortfolioInterface {
};
});
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details;
}
@ -644,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/**
* TODO: Refactor
*/

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface';
@Injectable()
@ -16,6 +17,7 @@ export class ConfigurationService {
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
@ -28,6 +30,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});

View File

@ -7,6 +7,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string;
REDIS_PORT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
}

View File

@ -10,6 +10,7 @@ export const MarketState = {
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',

View File

@ -5,13 +5,7 @@ module.exports = {
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
coverageDirectory: '../../coverage/apps/client',
@ -19,5 +13,6 @@ module.exports = {
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment'
]
],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
};

View File

@ -7,10 +7,6 @@
.create-account-box {
cursor: pointer;
font-size: 90%;
.link {
color: rgba(var(--palette-primary-500), 1);
}
}
}

View File

@ -6,7 +6,11 @@ import {
OnInit
} from '@angular/core';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
});
this.userService.stateChanged
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
}
}

View File

@ -15,7 +15,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule } from 'ngx-stripe';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter';
@ -27,6 +27,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
export function NgxStripeFactory(): string {
return environment.stripePublicKey;
}
@NgModule({
declarations: [AppComponent],
imports: [
@ -57,7 +61,11 @@ import { LanguageService } from './core/language.service';
useClass: CustomDateAdapter,
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
},
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
{
provide: STRIPE_PUBLISHABLE_KEY,
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
})

View File

@ -26,6 +26,27 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[locale]="locale"
[value]="element.balance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -53,15 +74,6 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

View File

@ -28,7 +28,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
@ -40,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -5,10 +5,7 @@
z-index: 999;
.mat-toolbar {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(var(--light-disabled-text));
.spacer {
flex: 1 1 auto;
@ -28,11 +25,6 @@
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: rgba(
39,
39,
39,
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(39, 39, 39, $alpha-disabled-text);
}
}

View File

@ -51,6 +51,7 @@ export class HeaderComponent implements OnChanges {
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((id) => {
this.impersonationId = id;
});
@ -98,23 +99,26 @@ export class HeaderComponent implements OnChanges {
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
}
public setToken(aToken: string) {
@ -125,4 +129,9 @@ export class HeaderComponent implements OnChanges {
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -5,8 +5,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '../logo/logo.module';
import { HeaderComponent } from './header.component';
@NgModule({

View File

@ -2,6 +2,7 @@ import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
@ -44,7 +45,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public chart: Chart;
public isLoading = true;
public constructor() {
public constructor(private changeDetectorRef: ChangeDetectorRef) {
Chart.register(
Filler,
LineController,
@ -59,7 +60,12 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
if (this.historicalDataItems) {
this.initialize();
setTimeout(() => {
// Wait for the chartCanvas
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
}
@ -79,8 +85,6 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
marketPrices.push(historicalDataItem.value);
});
const canvas = document.getElementById('chartCanvas');
const gradient = this.chartCanvas?.nativeElement
?.getContext('2d')
.createLinearGradient(
@ -89,11 +93,14 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
);
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
if (gradient) {
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
}
const data = {
labels,

View File

@ -1,4 +1,4 @@
<span class="align-items-center d-flex"
><span class="d-inline-block logo mr-1"></span>
<span class="name">Ghostfolio</span></span
<span *ngIf="!hideName" class="name">Ghostfolio</span></span
>

View File

@ -14,10 +14,12 @@ import {
})
export class LogoComponent implements OnInit {
@HostBinding('class') @Input() size: 'large' | 'medium';
@Input() hideName: boolean;
public constructor() {}
public ngOnInit() {
this.size = this.size || 'medium';
this.hideName = this.hideName ?? false;
this.size = this.size ?? 'medium';
}
}

View File

@ -1,9 +1,13 @@
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
<span i18n>Time to add your first transaction.</span>
</a>
<div class="p-3">
<div class="d-flex justify-content-center mb-1">
<gf-logo size="large" [hideName]="true"></gf-logo>
</div>
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<span i18n>Time to add your first transaction.</span>
</a>
</div>

View File

@ -1,3 +1,13 @@
:host {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
display: block;
gf-logo {
opacity: 0.25;
}
}
:host-context(.is-dark-theme) {
border-color: rgba(var(--light-dividers));
}

View File

@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
@NgModule({
declarations: [NoTransactionsInfoComponent],
exports: [NoTransactionsInfoComponent],
imports: [CommonModule, MatButtonModule, RouterModule],
imports: [CommonModule, GfLogoModule, MatButtonModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -7,6 +7,8 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -27,6 +29,8 @@ export class PerformanceChartDialog {
public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -35,6 +39,7 @@ export class PerformanceChartDialog {
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
@ -84,4 +89,9 @@ export class PerformanceChartDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,4 +1,18 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">

View File

@ -8,7 +8,7 @@ import {
ViewChild
} from '@angular/core';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js';
@ -43,9 +43,7 @@ export class PortfolioProportionChartComponent
private colorMap: {
[symbol: string]: string;
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
};
public constructor() {

View File

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -18,7 +21,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog {
export class PositionDetailDialog implements OnDestroy {
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
@ -33,6 +36,8 @@ export class PositionDetailDialog {
public quantity: number;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -41,6 +46,7 @@ export class PositionDetailDialog {
) {
this.dataService
.fetchPositionDetail(data.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
averagePrice,
@ -135,4 +141,9 @@ export class PositionDetailDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -72,8 +72,11 @@ export class PositionComponent implements OnDestroy, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -82,7 +82,13 @@
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: row.name })"
[ngClass]="{
'cursor-pointer': !this.ignoreTypes.includes(row.type)
}"
(click)="
!this.ignoreTypes.includes(row.type) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
"
></tr>
</table>

View File

@ -19,7 +19,9 @@
}
.mat-row {
cursor: pointer;
&.cursor-pointer {
cursor: pointer;
}
}
}
}

View File

@ -4,6 +4,7 @@ import {
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
@ -13,6 +14,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -26,7 +28,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
templateUrl: './positions-table.component.html',
styleUrls: ['./positions-table.component.scss']
})
export class PositionsTableComponent implements OnChanges, OnInit {
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@ -38,8 +40,10 @@ export class PositionsTableComponent implements OnChanges, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<PortfolioPosition> = new MatTableDataSource();
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreTypes = [Type.Cash];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;
@ -133,9 +137,12 @@ export class PositionsTableComponent implements OnChanges, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -1,12 +1,11 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(0, 0, 0, $alpha-hover);
}
}
}
@ -14,10 +13,7 @@
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(255, 255, 255, $alpha-hover);
}
}
}

View File

@ -5,7 +5,10 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import {
MarketState,
Type
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({
@ -25,6 +28,8 @@ export class PositionsComponent implements OnChanges, OnInit {
public positionsRest: PortfolioPosition[] = [];
public positionsWithPriority: PortfolioPosition[] = [];
private ignoreTypes = [Type.Cash];
public constructor() {}
public ngOnInit() {}
@ -41,6 +46,10 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const [, portfolioPosition] of Object.entries(this.positions)) {
if (this.ignoreTypes.includes(portfolioPosition.type)) {
continue;
}
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'

View File

@ -7,10 +7,7 @@
padding: 0.15rem 0.75rem;
&.mat-radio-checked {
background-color: rgba(
var(--dark-primary-text),
var(--palette-foreground-divider-alpha)
);
background-color: rgba(var(--dark-dividers));
}
::ng-deep {
@ -33,15 +30,8 @@
:host-context(.is-dark-theme) {
.mat-radio-button {
&.mat-radio-checked {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-divider-alpha)
);
border: 1px solid
rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-button-alpha)
);
background-color: rgba(var(--light-dividers));
border: 1px solid rgba(var(--light-disabled-text));
}
::ng-deep {

View File

@ -202,17 +202,45 @@
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
[matMenuTriggerFor]="transactionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<mat-menu #transactionsMenu="matMenu" xPosition="before">
<button
*ngIf="hasPermissionToImportOrders"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import</span>
</button>
<button
class="align-items-center d-flex"
mat-menu-item
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
Edit
</button>

View File

@ -27,7 +27,7 @@
cursor: pointer;
.type-badge {
background-color: rgba(var(--dark-primary-text), 0.05);
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
@ -54,7 +54,10 @@
.mat-table {
.type-badge {
background-color: rgba(var(--light-primary-text), 0.1);
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
}
}
}

View File

@ -43,10 +43,13 @@ export class TransactionsTableComponent
{
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToImportOrders: boolean;
@Input() locale: string;
@Input() showActions: boolean;
@Input() transactions: OrderWithAccount[];
@Output() export = new EventEmitter<void>();
@Output() import = new EventEmitter<void>();
@Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
@ -89,18 +92,20 @@ export class TransactionsTableComponent
}
});
this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
});
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((keyword) => {
if (keyword) {
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 {
@ -183,6 +188,14 @@ export class TransactionsTableComponent
}
}
public onExport() {
this.export.emit();
}
public onImport() {
this.import.emit();
}
public onOpenPositionDialog({
symbol,
title
@ -223,9 +236,12 @@ export class TransactionsTableComponent
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
@ -15,7 +15,7 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html',
styleUrls: ['./about-page.scss']
})
export class AboutPageComponent implements OnInit {
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForStatistics: boolean;
public isLoggedIn: boolean;
@ -39,18 +39,13 @@ export class AboutPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchInfo()
.subscribe(({ globalPermissions, statistics }) => {
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.statistics = statistics;
this.changeDetectorRef.markForCheck();
});
this.statistics = statistics;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -5,11 +5,18 @@
<mat-card>
<mat-card-content>
<p>
<strong>Ghostfolio</strong> is open source software which empowers
busy folks to have a sharp look of their financial assets and to
make solid, data-driven investment decisions by evaluating automated
static portfolio analysis rules. The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>.
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of their wealth like
stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions. The source code is fully available as open
source software (OSS). The project has been initiated by
<a href="https://dotsilver.ch">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

View File

@ -12,7 +12,7 @@ import {
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency, DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client';
@ -54,24 +54,19 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private userService: UserService,
public webAuthnService: WebAuthnService
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, globalPermissions, subscriptions }) => {
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;
const { currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
this.changeDetectorRef.markForCheck();
});
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -166,6 +161,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.webAuthnService
.deregister()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
@ -181,6 +177,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.webAuthnService
.register()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();

View File

@ -16,14 +16,19 @@
<div class="w-50" i18n>Membership</div>
<div class="w-50">
<div class="align-items-center d-flex mb-1">
{{ user.subscription.type }}
{{ user?.subscription?.type }}
<ion-icon
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date:
<div *ngIf="user?.subscription?.expiresAt">
Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }}
</div>
<div
*ngIf="hasPermissionForSubscription && !user.subscription.expiresAt"
*ngIf="hasPermissionForSubscription && !user?.subscription?.expiresAt"
>
<button
color="primary"
@ -46,45 +51,54 @@
</div>
</div>
<div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Settings</div>
<div class="w-50">
<form #changeUserSettingsForm="ngForm">
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Base Currency</mat-label>
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="flex-grow-1">
<mat-label i18n>View Mode</mat-label>
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 w-50" i18n>
Base Currency
</div>
<div class="w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pt-1 w-50" i18n>
View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="h5 mb-0 mx-3 text-muted"
class="mx-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
</form>
</div>
<div class="w-50">
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
@ -20,7 +20,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/c
templateUrl: './accounts-page.html',
styleUrls: ['./accounts-page.scss']
})
export class AccountsPageComponent implements OnInit {
export class AccountsPageComponent implements OnDestroy, OnInit {
public accounts: AccountModel[];
public deviceType: string;
public hasImpersonationId: boolean;
@ -71,6 +71,7 @@ export class AccountsPageComponent implements OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -98,23 +99,29 @@ export class AccountsPageComponent implements OnInit {
}
public fetchAccounts() {
this.dataService.fetchAccounts().subscribe((response) => {
this.accounts = response;
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.accounts = response;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public onDeleteAccount(aId: string) {
this.dataService.deleteAccount(aId).subscribe({
next: () => {
this.fetchAccounts();
}
});
this.dataService
.deleteAccount(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
public onUpdateAccount(aAccount: AccountModel) {
@ -125,6 +132,8 @@ export class AccountsPageComponent implements OnInit {
public openUpdateAccountDialog({
accountType,
balance,
currency,
id,
name,
platformId
@ -133,6 +142,8 @@ export class AccountsPageComponent implements OnInit {
data: {
account: {
accountType,
balance,
currency,
id,
name,
platformId
@ -142,19 +153,25 @@ export class AccountsPageComponent implements OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: UpdateAccountDto = data?.account;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const account: UpdateAccountDto = data?.account;
if (account) {
this.dataService.putAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
if (account) {
this.dataService
.putAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
@ -167,6 +184,8 @@ export class AccountsPageComponent implements OnInit {
data: {
account: {
accountType: AccountType.SECURITIES,
balance: 0,
currency: this.user?.settings?.baseCurrency,
name: null,
platformId: null
}
@ -175,18 +194,24 @@ export class AccountsPageComponent implements OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: CreateAccountDto = data?.account;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const account: CreateAccountDto = data?.account;
if (account) {
this.dataService.postAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
if (account) {
this.dataService
.postAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
@ -13,23 +19,24 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-account-dialog.scss'],
templateUrl: 'create-or-update-account-dialog.html'
})
export class CreateOrUpdateAccountDialog {
export class CreateOrUpdateAccountDialog implements OnDestroy {
public currencies: Currency[] = [];
public platforms: { id: string; name: string }[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
) {}
ngOnInit() {
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
});
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
this.platforms = platforms;
}
public onCancel(): void {

View File

@ -8,14 +8,37 @@
<input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field>
</div>
<div class="d-none">
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option value="SECURITIES" i18n> SECURITIES </mat-option>
<mat-option value="CASH" i18n>Cash</mat-option>
<mat-option value="SECURITIES" i18n>Securities</mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select name="currency" required [(value)]="data.account.currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Balance</mat-label>
<input
matInput
name="balance"
required
type="number"
[(ngModel)]="data.account.balance"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -19,7 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-page.html',
styleUrls: ['./admin-page.scss']
})
export class AdminPageComponent implements OnInit {
export class AdminPageComponent implements OnDestroy, OnInit {
public dataGatheringInProgress: boolean;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[];
@ -58,11 +58,14 @@ export class AdminPageComponent implements OnInit {
}
public onFlushCache() {
this.cacheService.flush().subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
@ -71,11 +74,14 @@ export class AdminPageComponent implements OnInit {
);
if (confirmation === true) {
this.adminService.gatherMax().subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
@ -98,11 +104,14 @@ export class AdminPageComponent implements OnInit {
const confirmation = confirm('Do you really want to delete this user?');
if (confirmation) {
this.dataService.deleteUser(aId).subscribe({
next: () => {
this.fetchAdminData();
}
});
this.dataService
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAdminData();
}
});
}
}

View File

@ -1,17 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-auth-page',
templateUrl: './auth-page.html',
styleUrls: ['./auth-page.scss']
})
export class AuthPageComponent implements OnInit {
export class AuthPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
@ -26,14 +30,21 @@ export class AuthPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.route.params.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.route.params
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']);
});
this.router.navigate(['/']);
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -116,6 +116,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -148,9 +149,12 @@ export class HomePageComponent implements OnDestroy, OnInit {
width: '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {
@ -161,6 +165,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
@ -174,6 +179,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
@ -181,15 +187,19 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.dataService
.fetchPortfolioOverview()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response;
this.hasPositions =

View File

@ -3,7 +3,6 @@ import { Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
@ -33,13 +32,11 @@ export class LandingPageComponent implements OnDestroy, OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.dataService.fetchInfo().subscribe(({ demoAuthToken }) => {
this.demoAuthToken = demoAuthToken;
const { demoAuthToken } = this.dataService.fetchInfo();
this.initializeLineChart();
this.demoAuthToken = demoAuthToken;
this.changeDetectorRef.markForCheck();
});
this.initializeLineChart();
}
public initializeLineChart() {

View File

@ -1,16 +1,29 @@
<div class="container">
<div class="row">
<div
class="align-items-center d-flex flex-column justify-content-center mb-4 w-100"
class="
align-items-center
d-flex
flex-column
justify-content-center
mb-4
w-100
"
>
<gf-logo size="large"></gf-logo>
<p class="lead m-0">Open Source Portfolio Tracker</p>
<p class="lead m-0">Wealth Management Software</p>
</div>
</div>
<div class="button-container row">
<div
class="align-items-center col d-flex justify-content-center position-relative"
class="
align-items-center
col
d-flex
justify-content-center
position-relative
"
>
<div class="py-5 text-center">
<a
@ -51,10 +64,9 @@
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<strong>Ghostfolio</strong> empowers busy folks to have a sharp look of
their financial assets and to make solid, data-driven investment
decisions by evaluating automated
<strong>Static Portfolio Analysis Rules</strong>.
<strong>Ghostfolio</strong> empowers busy people to keep track of their
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions.
</p>
</div>
</div>

View File

@ -3,7 +3,6 @@
.button-container {
aspect-ratio: 16 / 9;
margin-top: -4rem;
gf-line-chart {
bottom: 0;

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
@ -11,7 +11,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './pricing-page.html',
styleUrls: ['./pricing-page.scss']
})
export class PricingPageComponent implements OnInit {
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public coupon: number;
public isLoggedIn: boolean;
@ -28,15 +28,10 @@ export class PricingPageComponent implements OnInit {
private dataService: DataService,
private userService: UserService
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ subscriptions }) => {
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
const { subscriptions } = this.dataService.fetchInfo();
this.changeDetectorRef.markForCheck();
});
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
}
/**

View File

@ -8,9 +8,10 @@
<mat-card-content>
<p>
Our official
<strong>Ghostfolio</strong> cloud offering is the easiest way to get
started. Due to the time it saves, this will be the best option for
most people. The revenue is used for covering the hosting costs.
<strong>Ghostfolio Premium</strong> cloud offering is the easiest
way to get started. Due to the time it saves, this will be the best
option for most people. The revenue is used for covering the hosting
costs.
</p>
<p>
If you prefer to run <strong>Ghostfolio</strong> on your own

View File

@ -14,6 +14,7 @@
.mat-card {
&.active {
border-color: rgba(var(--palette-primary-500), 1);
box-shadow: 0 0 0 1px rgba(var(--palette-primary-500), 1);
}
}
}

View File

@ -41,17 +41,13 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchInfo()
.subscribe(({ demoAuthToken, globalPermissions }) => {
this.demoAuthToken = demoAuthToken;
this.hasPermissionForSocialLogin = hasPermission(
globalPermissions,
permissions.enableSocialLogin
);
const { demoAuthToken, globalPermissions } = this.dataService.fetchInfo();
this.changeDetectorRef.markForCheck();
});
this.demoAuthToken = demoAuthToken;
this.hasPermissionForSocialLogin = hasPermission(
globalPermissions,
permissions.enableSocialLogin
);
}
public async createAccount() {
@ -76,13 +72,16 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken, true);
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']);
}
});
this.router.navigate(['/']);
}
});
}
public ngOnDestroy() {

View File

@ -1,9 +1,4 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
@ -15,18 +10,11 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog';
export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {}
ngOnInit() {}
public enableAgreeButton() {
setTimeout(() => {
this.isAgreeButtonDisabled = false;
this.changeDetectorRef.markForCheck();
}, 1500);
this.isAgreeButtonDisabled = false;
}
}

View File

@ -32,11 +32,11 @@
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="isAgreeButtonDisabled"
[mat-dialog-close]="data"
>
Agree and continue
<span i18n>Agree and continue</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</button>
</div>

View File

@ -16,7 +16,7 @@
</div>
<div>
<a
href="https://money.cnn.com/data/fear-and-greed"
href="https://money.cnn.com/data/fear-and-greed/"
target="_blank"
>Fear & Greed Index →</a
>

View File

@ -9,7 +9,6 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -30,12 +29,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public hasImpersonationId: boolean;
public portfolioItems: PortfolioItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -9,7 +9,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './report-page.html',
styleUrls: ['./report-page.scss']
})
export class ReportPageComponent implements OnInit {
export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];

View File

@ -2,7 +2,8 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
@ -23,12 +24,12 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'create-or-update-transaction-dialog',
selector: 'gf-create-or-update-transaction-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-transaction-dialog.scss'],
templateUrl: 'create-or-update-transaction-dialog.html'
})
export class CreateOrUpdateTransactionDialog {
export class CreateOrUpdateTransactionDialog implements OnDestroy {
public currencies: Currency[] = [];
public currentMarketPrice = null;
public filteredLookupItems: Observable<LookupItem[]>;
@ -49,10 +50,10 @@ export class CreateOrUpdateTransactionDialog {
) {}
ngOnInit() {
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
});
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
this.platforms = platforms;
this.filteredLookupItems = this.searchSymbolCtrl.valueChanges.pipe(
startWith(''),
@ -73,6 +74,7 @@ export class CreateOrUpdateTransactionDialog {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,6 +2,22 @@
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update transaction</h1>
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add transaction</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-option
*ngFor="let account of data.user?.accounts"
[value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol or ISIN</mat-label>
@ -42,7 +58,7 @@
</mat-select>
</mat-form-field>
</div>
<div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select
@ -136,31 +152,25 @@
</button>
</mat-form-field>
</div>
</div>
<div class="d-flex" mat-dialog-actions>
<gf-value
class="flex-grow-1"
[currency]="data.transaction.currency"
[locale]="data.user?.settings?.locale"
[value]="data.transaction.fee + (data.transaction.quantity * data.transaction.unitPrice)"
></gf-value>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>
</mat-form-field>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.symbol)"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.symbol)"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

View File

@ -9,6 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfValueModule } from '@ghostfolio/client/components/value/value.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';
@ -19,6 +20,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
imports: [
CommonModule,
GfSymbolModule,
GfValueModule,
FormsModule,
MatAutocompleteModule,
MatButtonModule,

View File

@ -1,6 +1,12 @@
:host {
display: block;
.mat-dialog-actions {
gf-value {
font-size: 0.9rem;
}
}
.mat-dialog-content {
max-height: unset;

View File

@ -1,7 +1,8 @@
import { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;
accounts: Account[];
transaction: Order;
user: User;
}

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
@ -9,9 +10,10 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { EMPTY, Subject, Subscription } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
@ -20,11 +22,12 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
templateUrl: './transactions-page.html',
styleUrls: ['./transactions-page.scss']
})
export class TransactionsPageComponent implements OnInit {
export class TransactionsPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPermissionToDeleteOrder: boolean;
public hasPermissionToImportOrders: boolean;
public routeQueryParams: Subscription;
public transactions: OrderModel[];
public user: User;
@ -42,6 +45,7 @@ export class TransactionsPageComponent implements OnInit {
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private snackBar: MatSnackBar,
private userService: UserService
) {
this.routeQueryParams = route.queryParams
@ -67,10 +71,18 @@ export class TransactionsPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToImportOrders = hasPermission(
globalPermissions,
permissions.enableImport
);
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -98,15 +110,18 @@ export class TransactionsPageComponent implements OnInit {
}
public fetchOrders() {
this.dataService.fetchOrders().subscribe((response) => {
this.transactions = response;
this.dataService
.fetchOrders()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.transactions = response;
if (this.transactions?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
if (this.transactions?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public onCloneTransaction(aTransaction: OrderModel) {
@ -114,11 +129,78 @@ export class TransactionsPageComponent implements OnInit {
}
public onDeleteTransaction(aId: string) {
this.dataService.deleteOrder(aId).subscribe({
next: () => {
this.fetchOrders();
}
});
this.dataService
.deleteOrder(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
}
});
}
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
this.downloadAsFile(
data,
`ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
});
}
public onImport() {
const input = document.createElement('input');
input.type = 'file';
input.onchange = (event) => {
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = (readerEvent) => {
try {
const content = JSON.parse(readerEvent.target.result as string);
this.snackBar.open('⏳ Importing data...');
this.dataService
.postImport({
orders: content.orders
})
.pipe(
catchError((error) => {
this.handleImportError(error);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe({
next: () => {
this.fetchOrders();
this.snackBar.open('✅ Import has been completed', undefined, {
duration: 3000
});
}
});
} catch (error) {
this.handleImportError(error);
}
};
};
input.click();
}
public onUpdateTransaction(aTransaction: OrderModel) {
@ -141,7 +223,6 @@ export class TransactionsPageComponent implements OnInit {
}: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user.accounts,
transaction: {
accountId,
currency,
@ -153,25 +234,32 @@ export class TransactionsPageComponent implements OnInit {
symbol,
type,
unitPrice
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
if (transaction) {
this.dataService.putOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
if (transaction) {
this.dataService
.putOrder(transaction)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
@ -179,10 +267,28 @@ export class TransactionsPageComponent implements OnInit {
this.unsubscribeSubject.complete();
}
private downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType
});
a.href = URL.createObjectURL(file);
a.download = aFileName;
a.click();
}
private handleImportError(aError: unknown) {
console.error(aError);
this.snackBar.open('❌ Oops, something went wrong...');
}
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user?.accounts,
transaction: {
accountId:
aTransaction?.accountId ??
@ -197,24 +303,28 @@ export class TransactionsPageComponent implements OnInit {
symbol: aTransaction?.symbol ?? null,
type: aTransaction?.type ?? 'BUY',
unitPrice: null
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction;
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -5,9 +5,12 @@
<gf-transactions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToImportOrders]="hasPermissionToImportOrders"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder"
[transactions]="transactions"
(export)="onExport()"
(import)="onImport()"
(transactionDeleted)="onDeleteTransaction($event)"
(transactionToClone)="onCloneTransaction($event)"
(transactionToUpdate)="onUpdateTransaction($event)"

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
@ -16,6 +17,7 @@ import { TransactionsPageComponent } from './transactions-page.component';
CreateOrUpdateTransactionDialogModule,
GfTransactionsTableModule,
MatButtonModule,
MatSnackBarModule,
RouterModule,
TransactionsPageRoutingModule
],

View File

@ -1,16 +1,20 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-webauthn-page',
templateUrl: './webauthn-page.html',
styleUrls: ['./webauthn-page.scss']
})
export class WebauthnPageComponent implements OnInit {
export class WebauthnPageComponent implements OnDestroy, OnInit {
public hasError = false;
private unsubscribeSubject = new Subject<void>();
constructor(
private changeDetectorRef: ChangeDetectorRef,
private router: Router,
@ -23,24 +27,35 @@ export class WebauthnPageComponent implements OnInit {
}
public deregisterDevice() {
this.webAuthnService.deregister().subscribe(() => {
this.router.navigate(['/']);
});
this.webAuthnService
.deregister()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['/']);
});
}
public signIn() {
this.hasError = false;
this.webAuthnService.login().subscribe(
({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
this.router.navigate(['/']);
},
(error) => {
console.error(error);
this.hasError = true;
this.changeDetectorRef.markForCheck();
}
);
this.webAuthnService
.login()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
this.router.navigate(['/']);
},
(error) => {
console.error(error);
this.hasError = true;
this.changeDetectorRef.markForCheck();
}
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -20,6 +20,7 @@ export class ZenPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToReadForeignPortfolio: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
@ -61,6 +62,7 @@ export class ZenPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -78,6 +80,7 @@ export class ZenPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
@ -86,11 +89,14 @@ export class ZenPageComponent implements OnDestroy, OnInit {
};
});
this.hasPositions = this.historicalDataItems?.length > 0;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;

View File

@ -1,4 +1,4 @@
<div class="container">
<div *ngIf="hasPositions || !historicalDataItems" class="container">
<div class="row">
<div class="chart-container col mr-3">
<gf-line-chart
@ -23,3 +23,10 @@
</div>
</div>
</div>
<div
*ngIf="!hasPositions && historicalDataItems"
class="d-flex justify-content-center my-5"
>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPortfolioPerformanceSummaryModule } from '@ghostfolio/client/components/portfolio-performance-summary/portfolio-performance-summary.module';
import { ZenPageRoutingModule } from './zen-page-routing.module';
@ -13,6 +14,7 @@ import { ZenPageComponent } from './zen-page.component';
imports: [
CommonModule,
GfLineChartModule,
GfNoTransactionsInfoModule,
GfPortfolioPerformanceSummaryModule,
MatCardModule,
ZenPageRoutingModule

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import {
@ -15,6 +16,7 @@ import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-sett
import {
Access,
AdminData,
Export,
InfoItem,
PortfolioItem,
PortfolioOverview,
@ -27,6 +29,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { Account as AccountModel } from '@prisma/client';
import { parseISO } from 'date-fns';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -86,21 +89,20 @@ export class DataService {
});
}
public fetchInfo() {
return this.http.get<InfoItem>('/api/info').pipe(
map((data) => {
if (
this.settingsStorageService.getSetting('utm_source') ===
'trusted-web-activity'
) {
data.globalPermissions = data.globalPermissions.filter(
(permission) => permission !== permissions.enableSubscription
);
}
public fetchExport() {
return this.http.get<Export>('/api/export');
}
return data;
})
);
public fetchInfo(): InfoItem {
const info = cloneDeep((window as any).info);
if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') {
info.globalPermissions = info.globalPermissions.filter(
(permission) => permission !== permissions.enableSubscription
);
}
return info;
}
public fetchSymbolItem(aSymbol: string) {
@ -168,6 +170,10 @@ export class DataService {
return this.http.post<OrderModel>(`/api/account`, aAccount);
}
public postImport(aImportData: ImportDataDto) {
return this.http.post<void>('/api/import', aImportData);
}
public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/order`, aOrder);
}

View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://ghostfol.io/sitemap.xml

View File

@ -1,7 +1,7 @@
{
"background_color": "transparent",
"categories": ["finance", "utilities"],
"description": "Open Source Portfolio Tracker",
"description": "Open Source Wealth Management Software",
"display": "standalone",
"icons": [
{

View File

@ -1,6 +1,6 @@
export const environment = {
lastPublish: '{BUILD_TIMESTAMP}',
production: true,
stripePublicKey: '{STRIPE_PUBLIC_KEY}',
stripePublicKey: '',
version: `v${require('../../../../package.json').version}`
};

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Ghostfolio Open Source Portfolio Tracker</title>
<title>Ghostfolio Open Source Wealth Management Software</title>
<base href="/" />
<meta charset="utf-8" />
<meta content="yes" name="apple-mobile-web-app-capable" />
@ -11,13 +11,13 @@
/>
<meta
name="keywords"
content="asset, cryptocurrency, etf, finance, performance, portfolio, stock, tracker, trading"
content="app, asset, cryptocurrency, etf, finance, management, performance, portfolio, software, stock, tracker, trading, wealth"
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:description"
content="Ghostfolio is a lightweight application to keep track of your financial assets like stocks, ETFs or cryptocurrencies across multiple platforms."
content="Ghostfolio is a lightweight wealth management application for individuals to keep track of their wealth like stocks, ETFs or cryptocurrencies"
/>
<meta
name="twitter:image"
@ -25,13 +25,13 @@
/>
<meta
name="twitter:title"
content="Ghostfolio Open Source Portfolio Tracker"
content="Ghostfolio Open Source Wealth Management Software"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:description" content="" />
<meta
property="og:title"
content="Ghostfolio Open Source Portfolio Tracker"
content="Ghostfolio Open Source Wealth Management Software"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.ghostfol.io" />
@ -42,7 +42,7 @@
<meta property="og:updated_time" content="2021-03-20T00:00:00+00:00" />
<meta
property="og:site_name"
content="Ghostfolio Open Source Portfolio Tracker"
content="Ghostfolio Open Source Wealth Management Software"
/>
<link

View File

@ -1,16 +1,33 @@
import { enableProdMode } from '@angular/core';
import { LOCALE_ID } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
(async () => {
const response = await fetch('/api/info');
const info: InfoItem = await response.json();
platformBrowserDynamic()
.bootstrapModule(AppModule, {
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }]
})
.catch((err) => console.error(err));
if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') {
info.globalPermissions = info.globalPermissions.filter(
(permission) => permission !== permissions.enableSubscription
);
}
(window as any).info = info;
environment.stripePublicKey = info.stripePublicKey;
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic()
.bootstrapModule(AppModule, {
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }]
})
.catch((err) => console.error(err));
})();

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