Compare commits

...

19 Commits

Author SHA1 Message Date
fca3a659d0 Release 1.78.0 (#481) 2021-11-20 10:31:45 +01:00
904dec040e Feature/add testimonial (#479)
* Add testimonials

* Update changelog
2021-11-20 10:28:05 +01:00
fc6c81fe02 Bugfix/fix footer row border in dark mode (#478)
* Fix border color in dark mode

* Update changelog
2021-11-17 23:23:32 +01:00
634171e4e3 Release 1.77.0 (#476) 2021-11-16 21:58:41 +01:00
f8f36e4f4e Bugfix/fix accounts table footer on mobile (#475)
* Fix footer on mobile

* Update changelog
2021-11-16 21:32:04 +01:00
5e7cf9d0b6 Feature/hide get started button on registration page (#474)
* Hide button

* Update changelog
2021-11-15 20:49:03 +01:00
e1932eb5a1 Bugfix/exclude drafts from transaction count (#473)
* Fix transactions count (exclude drafts)

* Improve wording (summary page)

* Update changelog
2021-11-14 19:06:54 +01:00
dba47d59e3 Release 1.76.0 (#472) 2021-11-14 17:16:06 +01:00
3032126508 Feature/add footer row to accounts table (#471)
* Add footer row to accounts table with total balance and value

* Update changelog
2021-11-14 17:04:52 +01:00
a50b55da75 Clean up (#470) 2021-11-14 16:57:46 +01:00
5422df05b3 Release 1.75.0 (#469) 2021-11-13 20:50:02 +01:00
d2fabe7ce4 Feature/add value column to accounts table (#468)
* Add value column

* Update changelog
2021-11-13 20:38:29 +01:00
a42700b9fe Feature/introduce data gathering progress (#467)
* Add data gathering progress

* Update changelog
2021-11-13 11:32:28 +01:00
9df8541145 Feature/log logo on server start (#466)
* Log logo on server start

* Update changelog
2021-11-12 22:50:40 +01:00
0b2252755c Release 1.74.0 (#464) 2021-11-11 21:52:57 +01:00
239bd09cbd Feature/move market mood to tab (#463)
* Move market mood to tab

* Update changelog
2021-11-11 21:43:17 +01:00
cd76f89902 Feature/increase decimal places for cryptocurrencies (#462)
* Calculate quantity precision

* Update changelog
2021-11-11 21:21:37 +01:00
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
49 changed files with 548 additions and 206 deletions

View File

@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.78.0 - 20.11.2021
### Added
- Added a testimonial section to the landing page
### Fixed
- Fixed the footer row border of the accounts table in dark mode
## 1.77.0 - 16.11.2021
### Changed
- Hid the _Get Started_ button on the registration page
### Fixed
- Fixed the footer row of the accounts table on mobile
- Fixed the transactions count calculation in the accounts table (exclude drafts)
## 1.76.0 - 14.11.2021
### Added
- Added the footer row with buying power and net worth to the accounts table
## 1.75.0 - 13.11.2021
### Added
- Added a logo to the log on the server start
- Added the data gathering progress to the log and the admin control panel
- Added the value column to the accounts table
## 1.74.0 - 11.11.2021
### Changed
- Adapted the decimal places for cryptocurrencies in the position detail dialog
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
## 1.73.0 - 10.11.2021
### Changed
- Improved the info messages to add the first transaction
### Fixed
- Fixed the skeleton loader of the portfolio holdings
## 1.72.0 - 08.11.2021 ## 1.72.0 - 08.11.2021
### Changed ### Changed

View File

@ -1,6 +1,11 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import {
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasPermission, hasPermission,
@ -34,6 +39,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -85,30 +91,39 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> { ): Promise<Accounts> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
let accounts = await this.accountService.getAccounts( let accountsWithAggregations =
impersonationUserId || this.request.user.id await this.portfolioService.getAccountsWithAggregations(
); impersonationUserId || this.request.user.id
);
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
accounts = nullifyValuesInObjects(accounts, [ accountsWithAggregations = {
'balance', ...nullifyValuesInObject(accountsWithAggregations, [
'fee', 'totalBalance',
'quantity', 'totalValue'
'unitPrice' ]),
]); accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
} }
return accounts; return accountsWithAggregations;
} }
@Get(':id') @Get(':id')

View File

@ -1,3 +1,4 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@Module({ @Module({
controllers: [AccountController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
RedisCacheModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule,
UserModule UserModule
], ],
controllers: [AccountController],
providers: [AccountService] providers: [AccountService]
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -85,7 +85,15 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length }; let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order; delete result.Order;

View File

@ -20,6 +20,8 @@ export class AdminService {
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
@ -58,7 +60,7 @@ export class AdminService {
return 'IN_PROGRESS'; return 'IN_PROGRESS';
} }
return null; return undefined;
} }
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {

View File

@ -1,4 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number; averagePrice: number;
currency: string; currency: string;
firstBuyDate: string; firstBuyDate: string;

View File

@ -18,6 +18,7 @@ import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,

View File

@ -1,5 +1,3 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -30,6 +28,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioReport, PortfolioReport,
@ -39,6 +38,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
AccountWithValue,
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -81,6 +81,59 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
}),
this.getDetails(aUserId, aUserId)
]);
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.name]?.current ?? 0
};
delete result.Order;
return result;
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
}
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
@ -258,7 +311,7 @@ export class PortfolioService {
value: totalValue value: totalValue
}); });
const accounts = await this.getAccounts( const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
@ -299,6 +352,8 @@ export class PortfolioService {
}; };
} }
const assetClass = orders[0].SymbolProfile?.assetClass;
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
@ -412,6 +467,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
currency, currency,
firstBuyDate, firstBuyDate,
grossPerformance, grossPerformance,
@ -467,6 +524,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
@ -613,7 +672,7 @@ export class PortfolioService {
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance, currentNetPerformance,
currentNetPerformancePercent, currentNetPerformancePercent,
currentValue: currentValue currentValue
} }
}; };
} }
@ -663,7 +722,7 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getAccounts( const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
currency, currency,
@ -863,7 +922,7 @@ export class PortfolioService {
}; };
} }
private async getAccounts( private async getValueOfAccounts(
orders: OrderWithAccount[], orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition }, portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: string, userCurrency: string,
@ -878,32 +937,20 @@ export class PortfolioService {
return accountId === account.id; return accountId === account.id;
}); });
if (ordersByAccount.length <= 0) { const convertedBalance = this.exchangeRateDataService.toCurrency(
// Add account without orders account.balance,
const balance = this.exchangeRateDataService.toCurrency( account.currency,
account.balance, userCurrency
account.currency, );
userCurrency accounts[account.name] = {
); current: convertedBalance,
accounts[account.name] = { original: convertedBalance
current: balance, };
original: balance
};
continue;
}
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency( let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice, order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.currency, let originalValueOfSymbol = order.quantity * order.unitPrice;
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') { if (order.type === 'SELL') {
currentValueOfSymbol *= -1; currentValueOfSymbol *= -1;

View File

@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -18,8 +19,23 @@ async function bootstrap() {
const port = process.env.PORT || 3333; const port = process.env.PORT || 3333;
await app.listen(port, () => { await app.listen(port, () => {
Logger.log(`Listening at http://localhost:${port}`); logLogo();
Logger.log(`Listening at http://localhost:${port}`, '', false);
Logger.log('', '', false);
}); });
} }
function logLogo() {
Logger.log(' ________ __ ____ ___', '', false);
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___', '', false);
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\', '', false);
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /', '', false);
Logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`,
'',
false
);
Logger.log('', '', false);
}
bootstrap(); bootstrap();

View File

@ -18,7 +18,6 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
@ -26,13 +25,14 @@ import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -204,8 +204,11 @@ export class DataGatheringService {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false; let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
@ -263,6 +266,16 @@ export class DataGatheringService {
hasError = true; hasError = true;
Logger.error(error); Logger.error(error);
} }
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
);
}
symbolCounter += 1;
} }
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
@ -272,6 +285,16 @@ export class DataGatheringService {
} }
} }
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: 'LOCKED_DATA_GATHERING' }

View File

@ -15,6 +15,24 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
@ -37,6 +55,11 @@
<span>{{ element.Platform?.name }}</span> <span>{{ element.Platform?.name }}</span>
</div> </div>
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
@ -45,7 +68,12 @@
<span class="d-none d-sm-block" i18n>Transactions</span> <span class="d-none d-sm-block" i18n>Transactions</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.transactionCount }} <ng-container *ngIf="element.accountType === 'SECURITIES'">{{
element.transactionCount
}}</ng-container>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }}
</td> </td>
</ng-container> </ng-container>
@ -56,9 +84,39 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[currency]="element.currency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="element.balance" [value]="element.convertedBalance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Value
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.value"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>
@ -88,10 +146,16 @@
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading }"
></tr>
</table> </table>
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -10,6 +10,16 @@
} }
.mat-table { .mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {
@ -21,7 +31,14 @@
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-form-field { .mat-table {
color: rgba(var(--light-primary-text)); td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
} }
} }

View File

@ -24,6 +24,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() locale: string;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ -41,7 +44,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions', 'balance']; this.displayedColumns = [
'account',
'currency',
'platform',
'transactions',
'balance',
'value'
];
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');

View File

@ -270,6 +270,7 @@
Sign In Sign In
</button> </button>
<a <a
*ngIf="currentRoute !== 'register'"
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
i18n i18n

View File

@ -2,6 +2,5 @@ import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.i
export interface PositionDetailDialogParams { export interface PositionDetailDialogParams {
deviceType: string; deviceType: string;
fearAndGreedIndex: number;
historicalDataItems: LineChartItem[]; historicalDataItems: LineChartItem[];
} }

View File

@ -22,13 +22,11 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
}) })
export class PerformanceChartDialog { export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = 'S&P 500';
public benchmarkSymbol = 'VOO'; public benchmarkSymbol = 'VOO';
public currency: string; public currency: string;
public firstBuyDate: string; public firstBuyDate: string;
public marketPrice: number; public marketPrice: number;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -83,8 +81,6 @@ export class PerformanceChartDialog {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.title = `Performance vs. ${this.benchmarkLabel}`;
} }
public onClose(): void { public onClose(): void {

View File

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
mat-dialog-title mat-dialog-title
title="Performance"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="title"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
></gf-dialog-header> ></gf-dialog-header>
@ -11,7 +11,6 @@
class="mb-4" class="mb-4"
symbol="Performance" symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[benchmarkLabel]="benchmarkLabel"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[showGradient]="true" [showGradient]="true"
[showLegend]="true" [showLegend]="true"
@ -19,13 +18,6 @@
[showYAxis]="false" [showYAxis]="false"
></gf-line-chart> ></gf-line-chart>
</div> </div>
<div *ngIf="data.fearAndGreedIndex" class="container p-0">
<gf-fear-and-greed-index
class="d-flex flex-column justify-content-center"
[fearAndGreedIndex]="data.fearAndGreedIndex"
></gf-fear-and-greed-index>
</div>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -8,7 +8,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component'; import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({ @NgModule({
@ -18,7 +17,6 @@ import { PerformanceChartDialog } from './performance-chart-dialog.component';
CommonModule, CommonModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfFearAndGreedIndexModule,
GfLineChartModule, GfLineChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,

View File

@ -77,7 +77,7 @@
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n> <div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}} {transaction} other {transactions}}
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span> <span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
@ -132,7 +132,7 @@
</div> </div>
</div> </div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div> <div class="d-flex flex-grow-1" i18n>Cash (Buying Power)</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"

View File

@ -9,6 +9,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { AssetSubClass } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -23,6 +24,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy { export class PositionDetailDialog implements OnDestroy {
public assetSubClass: AssetSubClass;
public averagePrice: number; public averagePrice: number;
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
public currency: string; public currency: string;
@ -38,6 +40,7 @@ export class PositionDetailDialog implements OnDestroy {
public netPerformance: number; public netPerformance: number;
public netPerformancePercent: number; public netPerformancePercent: number;
public quantity: number; public quantity: number;
public quantityPrecision = 2;
public symbol: string; public symbol: string;
public transactionCount: number; public transactionCount: number;
@ -54,6 +57,7 @@ export class PositionDetailDialog implements OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
assetSubClass,
averagePrice, averagePrice,
currency, currency,
firstBuyDate, firstBuyDate,
@ -71,6 +75,7 @@ export class PositionDetailDialog implements OnDestroy {
symbol, symbol,
transactionCount transactionCount
}) => { }) => {
this.assetSubClass = assetSubClass;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.currency = currency; this.currency = currency;
@ -146,6 +151,18 @@ export class PositionDetailDialog implements OnDestroy {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
} else if (this.quantity > 10000000) {
this.quantityPrecision = 0;
}
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );

View File

@ -82,7 +82,7 @@
label="Quantity" label="Quantity"
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[precision]="2" [precision]="quantityPrecision"
[value]="quantity" [value]="quantity"
></gf-value> ></gf-value>
</div> </div>

View File

@ -103,7 +103,9 @@
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center"> <div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> <gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div> </div>
<div <div

View File

@ -24,7 +24,9 @@
></gf-position> ></gf-position>
</ng-container> </ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center"> <div *ngIf="!hasPositions" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> <gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div> </div>
</ng-container> </ng-container>
</div> </div>

View File

@ -2,7 +2,9 @@
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center"> <mat-card *ngIf="rules === null" class="my-2 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> <gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</mat-card> </mat-card>
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule> <gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>

View File

@ -270,3 +270,9 @@
width: '100%' width: '100%'
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>

View File

@ -10,6 +10,7 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,6 +23,7 @@ import { TransactionsTableComponent } from './transactions-table.component';
exports: [TransactionsTableComponent], exports: [TransactionsTableComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfNoTransactionsInfoModule,
GfPositionDetailDialogModule, GfPositionDetailDialogModule,
GfSymbolIconModule, GfSymbolIconModule,
GfSymbolModule, GfSymbolModule,

View File

@ -28,6 +28,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public hasPermissionToCreateAccount: boolean; public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean; public hasPermissionToDeleteAccount: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public totalBalance = 0;
public totalValue = 0;
public transactionCount = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -103,8 +106,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchAccounts() .fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ accounts, totalBalance, totalValue, transactionCount }) => {
this.accounts = response; this.accounts = accounts;
this.totalBalance = totalBalance;
this.totalValue = totalValue;
this.transactionCount = transactionCount;
if (this.accounts?.length <= 0) { if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });

View File

@ -8,6 +8,9 @@
[deviceType]="deviceType" [deviceType]="deviceType"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[totalBalance]="totalBalance"
[totalValue]="totalValue"
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)" (accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)" (accountToUpdate)="onUpdateAccount($event)"
></gf-accounts-table> ></gf-accounts-table>

View File

@ -22,6 +22,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class AdminPageComponent implements OnDestroy, OnInit { export class AdminPageComponent implements OnDestroy, OnInit {
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public lastDataGathering: string; public lastDataGathering: string;
@ -134,12 +135,14 @@ export class AdminPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
dataGatheringProgress,
exchangeRates, exchangeRates,
lastDataGathering, lastDataGathering,
transactionCount, transactionCount,
userCount, userCount,
users users
}) => { }) => {
this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates; this.exchangeRates = exchangeRates;
this.users = users; this.users = users;

View File

@ -35,14 +35,15 @@
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Last Data Gathering</div> <div class="w-50" i18n>Data Gathering</div>
<div class="w-50"> <div class="w-50">
<div> <div>
<ng-container *ngIf="lastDataGathering" <ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container >{{ lastDataGathering }}</ng-container
> >
<ng-container *ngIf="dataGatheringInProgress" i18n <ng-container *ngIf="dataGatheringInProgress" i18n
>In Progress</ng-container >In Progress ({{ dataGatheringProgress | percent : '1.2-2'
}})</ng-container
> >
</div> </div>
<div class="mt-2 overflow-hidden"> <div class="mt-2 overflow-hidden">

View File

@ -7,10 +7,7 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTabChangeEvent } from '@angular/material/tabs'; import { MatTabChangeEvent } from '@angular/material/tabs';
import { ActivatedRoute, Router } from '@angular/router';
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type'; import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -61,7 +58,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true; public isLoadingPerformance = true;
public isLoadingSummary = true; public isLoadingSummary = true;
@ -80,21 +76,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['performanceChartDialog']) {
this.openDialog();
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -173,25 +158,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openDialog(): void {
const dialogRef = this.dialog.open(PerformanceChartDialog, {
autoFocus: false,
data: {
deviceType: this.deviceType,
fearAndGreedIndex: this.fearAndGreedIndex,
historicalDataItems: this.historicalDataItems
},
width: '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() { private update() {
if (this.currentTabIndex === 0) { if (this.currentTabIndex === 0) {
this.isLoadingPerformance = true; this.isLoadingPerformance = true;
@ -225,7 +191,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.positions = response.positions; this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -23,12 +23,7 @@
" "
> >
<div class="row w-100"> <div class="row w-100">
<a <div class="chart-container col">
*ngIf="historicalDataItems?.length !== 0"
class="chart-container col"
[routerLink]="[]"
[queryParams]="{performanceChartDialog: true}"
>
<gf-line-chart <gf-line-chart
class="mr-3" class="mr-3"
symbol="Performance" symbol="Performance"
@ -38,19 +33,19 @@
[showXAxis]="false" [showXAxis]="false"
[showYAxis]="false" [showYAxis]="false"
></gf-line-chart> ></gf-line-chart>
</a> <div
<div *ngIf="historicalDataItems?.length === 0"
*ngIf="historicalDataItems?.length === 0" class="
class=" align-items-center
align-items-center chart-container
chart-container d-flex
d-flex justify-content-center
justify-content-center w-100
w-100 "
" >
> <div class="d-flex justify-content-center">
<div class="d-flex justify-content-center"> <gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> </div>
</div> </div>
</div> </div>
</div> </div>
@ -92,33 +87,25 @@
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"
></gf-toggle> ></gf-toggle>
</div> </div>
<ng-container *ngIf="hasPositions === true"> <mat-card class="p-0">
<mat-card class="p-0"> <mat-card-content>
<mat-card-content> <gf-positions
<gf-positions [baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="user?.settings?.baseCurrency" [deviceType]="deviceType"
[deviceType]="deviceType" [locale]="user?.settings?.locale"
[locale]="user?.settings?.locale" [positions]="positions"
[positions]="positions" [range]="dateRange"
[range]="dateRange" ></gf-positions>
></gf-positions> </mat-card-content>
</mat-card-content> </mat-card>
</mat-card> <div *ngIf="hasPermissionToCreateOrder" class="text-center">
<div *ngIf="hasPermissionToCreateOrder" class="text-center"> <a
<a class="mt-3"
class="mt-3" i18n
i18n mat-button
mat-button [routerLink]="['/portfolio', 'transactions']"
[routerLink]="['/portfolio', 'transactions']" >Manage Transactions...</a
>Manage Transactions...</a >
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"
>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div> </div>
</div> </div>
</div> </div>
@ -148,4 +135,33 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab *ngIf="hasPermissionToAccessFearAndGreedIndex">
<ng-template mat-tab-label>
<ion-icon name="newspaper-outline" size="large"></ion-icon>
</ng-template>
<div
class="
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
>
<div class="row w-100">
<div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100">
<mat-card-content>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
></gf-fear-and-greed-index>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group> </mat-tab-group>

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module'; import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module'; import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module'; import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfFearAndGreedIndexModule,
GfLineChartModule, GfLineChartModule,
GfNoTransactionsInfoModule, GfNoTransactionsInfoModule,
GfPerformanceChartDialogModule, GfPerformanceChartDialogModule,

View File

@ -27,7 +27,6 @@
&.overview { &.overview {
.chart-container { .chart-container {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
cursor: pointer;
max-height: 50vh; max-height: 50vh;
// Fallback for aspect-ratio (using padding hack) // Fallback for aspect-ratio (using padding hack)

View File

@ -14,6 +14,28 @@ import { Subject } from 'rxjs';
export class LandingPageComponent implements OnDestroy, OnInit { export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy'); public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string; public demoAuthToken: string;
public testimonials = [
{
author: 'Philipp',
country: 'Germany 🇩🇪',
quote: `Super slim app with a great user interface. On top of that, it's open source.`
},
{
author: 'Onur',
country: 'Switzerland 🇨🇭',
quote: `Ghostfolio looks like the perfect portfolio tracker that I've been searching for all these years.`
},
{
author: 'Ivo',
country: 'Netherlands 🇳🇱',
quote: `A fantastic open source app to track my investments across platforms. Love the simplicity of its design and the depth of the insights.`
},
{
author: 'Damjan',
country: 'Slovenia 🇸🇮',
quote: `Ghostfolio helps me track all my investments in one place, it has a built-in portfolio analyzer and a very neat, seamless user interface.`
}
];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

View File

@ -107,6 +107,31 @@
</div> </div>
</div> </div>
<div class="row my-5">
<div class="col-12">
<h2 class="h4 mb-1 text-center">
What our <strong>users</strong> are saying
</h2>
</div>
<div *ngFor="let testimonial of testimonials" class="col-md-6">
<div class="d-flex flex-row py-3">
<div class="d-flex justify-content-center">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"
[hideName]="true"
></gf-logo>
</div>
<div>
<div>{{ testimonial.quote }}</div>
<div class="mt-2 text-muted">
— {{ testimonial.author }}, {{ testimonial.country }}
</div>
</div>
</div>
</div>
</div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-6 offset-md-3"> <div class="col-md-6 offset-md-3">
<h2 class="h4 mb-1 text-center"> <h2 class="h4 mb-1 text-center">

View File

@ -38,7 +38,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true; public isLoadingPerformance = true;
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
@ -140,7 +139,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.positions = response.positions; this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -65,35 +65,25 @@
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
<div class="row"> <div class="row">
<div class="align-items-center col"> <div class="align-items-center col">
<ng-container *ngIf="hasPositions === true"> <mat-card class="p-0">
<mat-card class="p-0"> <mat-card-content>
<mat-card-content> <gf-positions
<gf-positions [baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="user?.settings?.baseCurrency" [deviceType]="deviceType"
[deviceType]="deviceType" [locale]="user?.settings?.locale"
[locale]="user?.settings?.locale" [positions]="positions"
[positions]="positions" [range]="dateRange"
[range]="dateRange" ></gf-positions>
></gf-positions> </mat-card-content>
</mat-card-content> </mat-card>
</mat-card> <div *ngIf="hasPermissionToCreateOrder" class="text-center">
<div *ngIf="hasPermissionToCreateOrder" class="text-center"> <a
<a class="mt-3"
class="mt-3" i18n
i18n mat-button
mat-button [routerLink]="['/portfolio', 'transactions']"
[routerLink]="['/portfolio', 'transactions']" >Manage Transactions...</a
>Manage Transactions...</a >
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"
>
<div>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,6 +17,7 @@ import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setti
import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto'; import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto';
import { import {
Access, Access,
Accounts,
AdminData, AdminData,
Export, Export,
InfoItem, InfoItem,
@ -29,7 +30,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { import {
Account as AccountModel, Account as AccountModel,
DataSource, DataSource,
@ -62,7 +63,7 @@ export class DataService {
} }
public fetchAccounts() { public fetchAccounts() {
return this.http.get<AccountModel[]>('/api/account'); return this.http.get<Accounts>('/api/account');
} }
public fetchAdminData() { public fetchAdminData() {

View File

@ -1,8 +1,12 @@
@mixin gf-table($darkTheme: false) { @mixin gf-table($darkTheme: false) {
background: transparent !important; background: transparent !important;
td { .mat-footer-row,
border: 0 !important; .mat-row {
.mat-cell,
.mat-footer-cell {
border-bottom: 0;
}
} }
.mat-row { .mat-row {

View File

@ -0,0 +1,8 @@
import { AccountWithValue } from '@ghostfolio/common/types';
export interface Accounts {
accounts: AccountWithValue[];
totalBalance: number;
totalValue: number;
transactionCount: number;
}

View File

@ -1,6 +1,7 @@
export interface AdminData { export interface AdminData {
dataGatheringProgress?: number;
exchangeRates: { label1: string; label2: string; value: number }[]; exchangeRates: { label1: string; label2: string; value: number }[];
lastDataGathering: Date | 'IN_PROGRESS'; lastDataGathering?: Date | 'IN_PROGRESS';
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;
users: { users: {

View File

@ -1,4 +1,5 @@
import { Access } from './access.interface'; import { Access } from './access.interface';
import { Accounts } from './accounts.interface';
import { AdminData } from './admin-data.interface'; import { AdminData } from './admin-data.interface';
import { Export } from './export.interface'; import { Export } from './export.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
@ -19,6 +20,7 @@ import { User } from './user.interface';
export { export {
Access, Access,
Accounts,
AdminData, AdminData,
Export, Export,
InfoItem, InfoItem,

View File

@ -0,0 +1,7 @@
import { Account as AccountModel } from '@prisma/client';
export type AccountWithValue = AccountModel & {
convertedBalance: number;
transactionCount: number;
value: number;
};

View File

@ -1,4 +1,5 @@
import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { AccountWithValue } from './account-with-value.type';
import type { DateRange } from './date-range.type'; import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type'; import type { Granularity } from './granularity.type';
import type { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
@ -6,6 +7,7 @@ import type { RequestWithUser } from './request-with-user.type';
export type { export type {
AccessWithGranteeUser, AccessWithGranteeUser,
AccountWithValue,
DateRange, DateRange,
Granularity, Granularity,
OrderWithAccount, OrderWithAccount,

View File

@ -6,6 +6,7 @@
class="align-items-center justify-content-center" class="align-items-center justify-content-center"
color="primary" color="primary"
[routerLink]="['/portfolio', 'transactions']" [routerLink]="['/portfolio', 'transactions']"
[queryParams]="{ createDialog: true }"
mat-button mat-button
> >
<span i18n>Time to add your first transaction.</span> <span i18n>Time to add your first transaction.</span>

View File

@ -1,8 +1,11 @@
:host { :host {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
display: block; display: block;
&.has-border {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
}
gf-logo { gf-logo {
opacity: 0.25; opacity: 0.25;
} }

View File

@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
HostBinding,
Input
} from '@angular/core';
@Component({ @Component({
selector: 'gf-no-transactions-info-indicator', selector: 'gf-no-transactions-info-indicator',
@ -6,8 +11,8 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
templateUrl: './no-transactions-info.component.html', templateUrl: './no-transactions-info.component.html',
styleUrls: ['./no-transactions-info.component.scss'] styleUrls: ['./no-transactions-info.component.scss']
}) })
export class NoTransactionsInfoComponent implements OnInit { export class NoTransactionsInfoComponent {
public constructor() {} @HostBinding('class.has-border') @Input() hasBorder = true;
public ngOnInit() {} public constructor() {}
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.72.0", "version": "1.78.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {