Compare commits

..

18 Commits

Author SHA1 Message Date
0578c645d1 Release 2.53.0 (#3021) 2024-02-18 14:39:10 +01:00
67ae86763e Handle premium data provider in getQuotes() (#3020)
* Handle premium data provider in getQuotes()
2024-02-18 14:37:42 +01:00
266c0a9a2c Feature/eliminate search request in get quotes of eod service (#3019)
* Eliminate search request to get quotes

* Update changelog
2024-02-18 14:14:25 +01:00
a3cdb23776 Feature/refactor query to filter activities of excluded accounts (#3016)
* Refactor query to filter activities of excluded accounts

* Update changelog
2024-02-18 12:29:00 +01:00
e1371a8d2b Clean up (#3018) 2024-02-18 12:27:34 +01:00
448cea0b69 Feature/improve usability of holdings table (#3017)
* Improve usability

* Update changelog
2024-02-18 10:18:53 +01:00
ad42c0bf28 Enable FAQ link for self-hoster (#3015) 2024-02-18 08:53:01 +01:00
f50670c7fe Feature/improve language localization for de 20240217 (#3014)
* Update translations

* Update changelog
2024-02-18 08:51:36 +01:00
c0029d3b1d Feature/upgrade ng extract i18n merge to version 2.10.0 (#3013)
* Upgrade ng-extract-i18n-merge to version 2.10.0

* Update changelog
2024-02-17 22:15:50 +01:00
2518a8fd9d Feature/add accounts tab to position detail dialog (#3012)
* Add accounts tab to position detail dialog

* Update changelog
2024-02-17 21:32:56 +01:00
572dcf075a Release 2.52.0 (#3011) 2024-02-16 20:05:01 +01:00
29cb83d469 Bugfix/improve x axis scale of dividend and investment timeline (#3010)
* Improve X-axis scale

* Update changelog
2024-02-16 20:03:20 +01:00
cac73ac111 Feature/divide faq page in three sections (#3003)
* Divide FAQ page in three sections

* General
* Cloud (SaaS)
* Self-Hosting

* Update changelog
2024-02-16 18:57:34 +01:00
02cf4295a9 Feature/add loading indicator to dividend and investment timelines (#3007)
* Add loading indicators

* Dividend timeline
* Investment timeline

* Update changelog
2024-02-16 09:43:51 +01:00
78b3328bf7 Add activities count (#3005) 2024-02-15 10:25:47 +01:00
e0d6d9e8ca Migrate if / else to control flow (#3001) 2024-02-13 20:46:21 +01:00
54310f2214 Feature/add support for jupiter cryptocurrency (#2999)
* Add JUP29210

* Update changelog
2024-02-13 19:46:15 +01:00
1fec49fbc2 Improve states (#3000) 2024-02-13 19:44:17 +01:00
91 changed files with 5875 additions and 4522 deletions

View File

@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.53.0 - 2024-02-18
### Added
- Added an accounts tab to the position detail dialog
### Changed
- Improved the usability of the holdings table
- Refactored the query to filter activities of excluded accounts
- Eliminated the search request to get quotes in the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0`
## 2.52.0 - 2024-02-16
### Added
- Added a loading indicator to the dividend timeline on the analysis page
- Added a loading indicator to the investment timeline on the analysis page
- Added support for the cryptocurrency _Jupiter_ (`JUP29210-USD`)
### Changed
- Divided the content of the Frequently Asked Questions (FAQ) page into three sections: _General_, _Cloud (SaaS)_ and _Self-Hosting_
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
## 2.51.0 - 2024-02-12
### Changed

View File

@ -226,7 +226,7 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
let marketData = assetProfiles.map(
let marketData: AdminMarketDataItem[] = assetProfiles.map(
({
_count,
assetClass,

View File

@ -60,10 +60,6 @@ export class InfoService {
const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(

View File

@ -301,6 +301,12 @@ export class OrderService {
});
}
if (withExcludedAccounts === false) {
where.Account = {
NOT: { isExcluded: true }
};
}
const [orders, count] = await Promise.all([
this.orders({
orderBy,
@ -322,32 +328,24 @@ export class OrderService {
this.prismaService.order.count({ where })
]);
const activities = orders
.filter((order) => {
return (
withExcludedAccounts ||
!order.Account ||
order.Account?.isExcluded === false
);
})
.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const activities = orders.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
order.SymbolProfile.currency,
userCurrency
)
};
});
return { activities, count };
}

View File

@ -107,7 +107,8 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService(
dataProviderService,
marketDataService
marketDataService,
null
);
});

View File

@ -6,8 +6,10 @@ import {
ResponseError,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { isBefore, isToday } from 'date-fns';
import { flatten, isEmpty, uniqBy } from 'lodash';
@ -19,9 +21,11 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
export class CurrentRateService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
// TODO: Pass user instead of using this.request.user
public async getValues({
dataGatheringItems,
dateQuery
@ -40,7 +44,7 @@ export class CurrentRateService {
if (includeToday) {
promises.push(
this.dataProviderService
.getQuotes({ items: dataGatheringItems })
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];

View File

@ -5,9 +5,10 @@ import {
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
import { Account, Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
accounts: Account[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -10,7 +10,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -420,7 +420,7 @@ export class PortfolioService {
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
@ -657,6 +657,7 @@ export class PortfolioService {
if (orders.length <= 0) {
return {
tags,
accounts: [],
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
@ -739,6 +740,13 @@ export class PortfolioService {
transactionCount
} = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
orders,
'Account.id'
).map(({ Account }) => {
return Account;
});
const dividendInBaseCurrency = getSum(
orders
.filter(({ type }) => {
@ -812,6 +820,7 @@ export class PortfolioService {
}
return {
accounts,
firstBuyDate,
marketPrice,
maxPrice,
@ -852,6 +861,7 @@ export class PortfolioService {
};
} else {
const currentData = await this.dataProviderService.getQuotes({
user,
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
});
const marketPrice = currentData[aSymbol]?.marketPrice;
@ -894,6 +904,7 @@ export class PortfolioService {
orders,
SymbolProfile,
tags,
accounts: [],
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
@ -929,6 +940,7 @@ export class PortfolioService {
return type === 'SEARCH_QUERY';
})?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -969,7 +981,7 @@ export class PortfolioService {
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };

View File

@ -1,5 +1,6 @@
{
"CYBER24781": "CyberConnect",
"JUP29210": "Jupiter",
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",

View File

@ -370,6 +370,14 @@
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/saas</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/self-hosting</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -27,7 +27,6 @@ export class ConfigurationService {
DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),

View File

@ -335,11 +335,13 @@ export class DataProviderService {
public async getQuotes({
items,
requestTimeout,
useCache = true
useCache = true,
user
}: {
items: UniqueAsset[];
requestTimeout?: number;
useCache?: boolean;
user?: UserWithSettings;
}): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
@ -405,6 +407,14 @@ export class DataProviderService {
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === 'Basic'
) {
continue;
}
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});

View File

@ -11,6 +11,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS
@ -35,7 +36,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
}
@ -228,27 +230,22 @@ export class EodHistoricalDataService implements DataProviderInterface {
? [realTimeResponse]
: realTimeResponse;
const searchResponse = await Promise.all(
eodHistoricalDataSymbols
.filter((symbol) => {
return !symbol.endsWith('.FOREX');
})
.map((symbol) => {
return this.search({ query: symbol });
})
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
symbols.map((symbol) => {
return {
symbol,
dataSource: this.getName()
};
})
);
const lookupItems = searchResponse.flat().map(({ items }) => {
return items[0];
});
response = quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }
) => {
const currency = lookupItems.find((lookupItem) => {
return lookupItem.symbol === code;
const currency = symbolProfiles.find(({ symbol }) => {
return symbol === code;
})?.currency;
if (isNumber(close)) {

View File

@ -15,7 +15,6 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

View File

@ -64,7 +64,7 @@
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForBlog">
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li>

View File

@ -38,7 +38,6 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
@ -81,11 +80,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
this.info?.globalPermissions,
permissions.enableBlog
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
@ -111,6 +105,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
@ -120,7 +115,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.showFooter =
(this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' ||

View File

@ -77,6 +77,7 @@
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>

View File

@ -277,14 +277,16 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
[ngClass]="{
'cursor-pointer': hasPermissionToOpenDetails
}"
(click)="onOpenAccountDetailDialog(row.id)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading }"
[ngClass]="{ 'd-none': isLoading || !showFooter }"
></tr>
</table>

View File

@ -26,8 +26,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() accounts: AccountModel[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToOpenDetails = true;
@Input() locale: string;
@Input() showActions: boolean;
@Input() showBalance = true;
@Input() showFooter = true;
@Input() showTransactions = true;
@Input() showValue = true;
@Input() showValueInBaseCurrency = true;
@Input() totalBalanceInBaseCurrency: number;
@Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number;
@ -51,17 +57,27 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = [
'status',
'account',
'platform',
'transactions',
'balance',
'value',
'currency',
'valueInBaseCurrency',
'comment'
];
this.displayedColumns = ['status', 'account', 'platform'];
if (this.showTransactions) {
this.displayedColumns.push('transactions');
}
if (this.showBalance) {
this.displayedColumns.push('balance');
}
if (this.showValue) {
this.displayedColumns.push('value');
}
this.displayedColumns.push('currency');
if (this.showValueInBaseCurrency) {
this.displayedColumns.push('valueInBaseCurrency');
}
this.displayedColumns.push('comment');
if (this.showActions) {
this.displayedColumns.push('actions');
@ -89,9 +105,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
public onOpenAccountDetailDialog(accountId: string) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
if (this.hasPermissionToOpenDetails) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
}
}
public onOpenComment(aComment: string) {

View File

@ -91,7 +91,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private snackBar: MatSnackBar
) {}
public ngOnInit(): void {
public ngOnInit() {
const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
@ -167,7 +167,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onClose(): void {
public onClose() {
this.dialogRef.close();
}

View File

@ -131,50 +131,48 @@
<ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
>
<ng-container
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts"
>
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.sectors[0].name"
>Sector</gf-value
>
</div>
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.countries[0].name"
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="countries"
/>
</div>
</ng-template>
@if (assetProfile?.countries?.length === 1 &&
assetProfile?.sectors?.length === 1 ) {
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.sectors[0].name"
>Sector</gf-value
>
</div>
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="assetProfile?.countries[0].name"
>Country</gf-value
>
</div>
} @else {
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="countries"
/>
</div>
}
</ng-container>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">

View File

@ -217,7 +217,7 @@ export class HeaderComponent implements OnChanges {
this.signOut.next();
}
public openLoginDialog(): void {
public openLoginDialog() {
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
autoFocus: false,
data: {

View File

@ -1,10 +1,8 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
class="justify-content-center row w-100"
>
@if(hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
<div class="justify-content-center row w-100">
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
@ -60,43 +58,43 @@
</div>
</div>
</div>
<ng-template #isUserActive>
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
/>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
} @else {
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
[unit]="unit"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
/>
</div>
</div>
</ng-template>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
[unit]="unit"
/>
</div>
</div>
}
</div>

View File

@ -38,8 +38,16 @@ import {
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
import { last } from 'lodash';
import {
addDays,
format,
isAfter,
isValid,
min,
parseISO,
subDays
} from 'date-fns';
import { first, last } from 'lodash';
@Component({
selector: 'gf-investment-chart',
@ -143,7 +151,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
});
}
const chartData: ChartData<'line'> = {
const chartData: ChartData<'bar' | 'line'> = {
labels: this.historicalDataItems.map(({ date }) => {
return parseDate(date);
}),
@ -194,17 +202,23 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
};
if (this.chartCanvas) {
let scaleXMin: string;
if (this.daysInMarket) {
const minDate = min([
parseDate(first(this.investments)?.date),
subDays(new Date().setHours(0, 0, 0, 0), this.daysInMarket)
]);
scaleXMin = isValid(minDate) ? minDate.toISOString() : undefined;
}
if (this.chart) {
this.chart.data = chartData;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.options.scales.x.min = this.daysInMarket
? subDays(
new Date().setHours(0, 0, 0, 0),
this.daysInMarket
).toISOString()
: undefined;
this.chart.options.scales.x.min = scaleXMin;
if (
this.savingsRate &&
@ -287,9 +301,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: {
display: false
},
min: this.daysInMarket
? subDays(new Date(), this.daysInMarket).toISOString()
: undefined,
min: scaleXMin,
suggestedMax: new Date().toISOString(),
type: 'time',
time: {

View File

@ -19,9 +19,9 @@ import {
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Tag } from '@prisma/client';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -36,6 +36,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog implements OnDestroy, OnInit {
public accounts: Account[];
public activities: OrderWithAccount[];
public assetClass: string;
public assetSubClass: string;
@ -83,7 +84,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
private userService: UserService
) {}
public ngOnInit(): void {
public ngOnInit() {
this.dataService
.fetchPositionDetail({
dataSource: this.data.dataSource,
@ -92,6 +93,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
accounts,
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
@ -113,6 +115,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
transactionCount,
value
}) => {
this.accounts = accounts;
this.activities = orders;
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
@ -264,7 +267,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
});
}
public onClose(): void {
public onClose() {
this.dialogRef.close();
}

View File

@ -186,57 +186,52 @@
<ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
>
<ng-container
*ngIf="SymbolProfile?.countries?.length === 1 && SymbolProfile?.sectors?.length === 1; else charts"
>
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.sectors[0].name"
>Sector</gf-value
>
</div>
<div
*ngIf="SymbolProfile?.countries?.length === 1"
class="col-6 mb-3"
@if(SymbolProfile?.countries?.length === 1 &&
SymbolProfile?.sectors?.length === 1) {
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.sectors[0].name"
>Sector</gf-value
>
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.countries[0].name"
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
/>
</div>
</ng-template>
</div>
<div *ngIf="SymbolProfile?.countries?.length === 1" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.countries[0].name"
>Country</gf-value
>
</div>
} @else {
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
/>
</div>
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
/>
</div>
}
</ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
<hr />
@ -246,9 +241,17 @@
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !activities?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<mat-tab-group
animationDuration="0"
class="mb-3"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }"
>
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="swap-vertical-outline" />
<div class="d-none d-sm-block ml-2" i18n>Activities</div>
</ng-template>
<gf-activities-table
[baseCurrency]="data.baseCurrency"
[dataSource]="dataSource"
@ -266,8 +269,26 @@
[totalItems]="totalItems"
(export)="onExport()"
/>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="wallet-outline" />
<div class="d-none d-sm-block ml-2" i18n>Accounts</div>
</ng-template>
<gf-accounts-table
[accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showBalance]="false"
[showFooter]="false"
[showTransactions]="false"
[showValue]="false"
[showValueInBaseCurrency]="false"
/>
</mat-tab>
</mat-tab-group>
<div *ngIf="tags?.length > 0" class="row">
<div class="col">

View File

@ -1,3 +1,4 @@
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
@ -11,6 +12,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PositionDetailDialog } from './position-detail-dialog.component';
@ -19,6 +21,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
declarations: [PositionDetailDialog],
imports: [
CommonModule,
GfAccountsTableModule,
GfActivitiesTableModule,
GfDataProviderCreditsModule,
GfDialogFooterModule,
@ -29,6 +32,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -100,7 +100,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog(): void {
private openCreateAccessDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {

View File

@ -3,7 +3,6 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import * as path from 'path';
import { AboutPageComponent } from './about-page.component';

View File

@ -8,7 +8,7 @@ import { AboutPageComponent } from './about-page.component';
@NgModule({
declarations: [AboutPageComponent],
imports: [CommonModule, MatTabsModule, AboutPageRoutingModule, RouterModule],
imports: [AboutPageRoutingModule, CommonModule, MatTabsModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AboutPageModule {}

View File

@ -13,7 +13,6 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './about-overview-page.html'
})
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
@ -30,11 +29,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
globalPermissions,
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics

View File

@ -133,16 +133,21 @@
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-6 col-xs-12 my-2">
<div
class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': hasPermissionForSubscription === false }"
>
<a
class="py-4 w-100"
color="primary"
i18n
mat-flat-button
[routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
@if (hasPermissionForSubscription) {
<div class="col-md-6 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
@ -151,5 +156,6 @@
>Blog</a
>
</div>
}
</div>
</div>

View File

@ -169,7 +169,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
isExcluded,
name,
platformId
}: AccountModel): void {
}: AccountModel) {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
@ -237,7 +237,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
});
}
private openCreateAccountDialog(): void {
private openCreateAccountDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
@ -279,7 +279,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
});
}
private openTransferBalanceDialog(): void {
private openTransferBalanceDialog() {
const dialogRef = this.dialog.open(TransferBalanceDialog, {
data: {
accounts: this.accounts

View File

@ -8,6 +8,27 @@ import { FaqPageComponent } from './faq-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
path: '',
loadChildren: () =>
import('./overview/faq-overview-page.module').then(
(m) => m.FaqOverviewPageModule
)
},
{
path: 'saas',
loadChildren: () =>
import('./saas/saas-page.module').then((m) => m.SaasPageModule)
},
{
path: 'self-hosting',
loadChildren: () =>
import('./self-hosting/self-hosting-page.module').then(
(m) => m.SelfHostingPageModule
)
}
],
component: FaqPageComponent,
path: '',
title: $localize`Frequently Asked Questions (FAQ)`

View File

@ -1,39 +1,57 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
host: { class: 'page has-tabs' },
selector: 'gf-faq-page',
styleUrls: ['./faq-page.scss'],
templateUrl: './faq-page.html'
})
export class FaqPageComponent implements OnDestroy {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public user: User;
export class FaqPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
private dataService: DataService,
private deviceService: DeviceDetectorService
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.tabs = [
{
iconName: 'reader-outline',
label: $localize`General`,
path: ['/' + $localize`faq`]
},
{
iconName: 'cloudy-outline',
label: $localize`Cloud` + ' (SaaS)',
path: ['/' + $localize`faq`, 'saas'],
showCondition: this.hasPermissionForSubscription
},
{
iconName: 'server-outline',
label: $localize`Self-Hosting`,
path: ['/' + $localize`faq`, $localize`self-hosting`]
}
];
}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {

View File

@ -1,291 +1,29 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Frequently Asked Questions (FAQ)
</h1>
<p>
Find quick answers to commonly asked questions about Ghostfolio in our
Frequently Asked Questions (FAQ) section. Discover what Ghostfolio is,
explore its features, and learn about our privacy practices. Get all the
information you need in one place.
</p>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>What is Ghostfolio?</mat-card-title>
</mat-card-header>
<mat-card-content>
Ghostfolio is a lightweight, open source wealth management application
for individuals to keep track of their net worth. The software
empowers you to make solid, data-driven investment decisions.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What assets can I track with Ghostfolio?</mat-card-title
>
</mat-card-header>
<mat-card-content>
With Ghostfolio, you can keep track of various assets like stocks,
ETFs, bonds, cryptocurrencies and commodities.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What else is included in Ghostfolio?</mat-card-title
></mat-card-header
>
<mat-card-content>
Please find a feature overview to manage your wealth
<a [routerLink]="routerLinkFeatures">here</a>.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I start?</mat-card-title>
</mat-card-header>
<mat-card-content>
You can sign up via the “<a [routerLink]="routerLinkRegister"
>Get Started</a
>” button at the top of the page. You have multiple options to join
Ghostfolio: Create an account with a security token or
<i>Google Sign</i>. We will guide you to set up your portfolio.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Will you spam me with emails once I sign up?</mat-card-title
></mat-card-header
>
<mat-card-content>
No, we do not even collect your email address, so you will not receive
any spam emails from us.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I use Ghostfolio anonymously?</mat-card-title
></mat-card-header
>
<mat-card-content>
Yes, the authentication system via security token enables you to sign
in securely and anonymously to Ghostfolio. There is no need for an
e-mail address, phone number, or a username.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How can Ghostfolio be free?</mat-card-title
></mat-card-header
>
<mat-card-content
>This project is driven by the efforts of contributors from around the
world. The
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
fully available as open source software (OSS). Thanks to our generous
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> users and
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we have
the ability to run a free, limited plan for novice
investors.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Is it really free?</mat-card-title></mat-card-header
>
<mat-card-content
>Yes, it is! Our
<a [routerLink]="routerLinkPricing">pricing page</a> details
everything you get for free.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Do you monetize or sell my financial data?</mat-card-title
></mat-card-header
>
<mat-card-content
>No, we value your privacy. We do not sell or share your financial
data with any third parties.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is your business model?</mat-card-title
></mat-card-header
>
<mat-card-content
>By offering
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a>, a
subscription plan with a managed hosting service and enhanced
features, we fund our business while providing added value to our
users.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is Ghostfolio Premium?</mat-card-title
></mat-card-header
>
<mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully
managed Ghostfolio cloud offering for ambitious investors. Revenue is
used to cover the costs of the hosting infrastructure and to fund
ongoing development. It is the Open Source code base with some extras
like the <a [routerLink]="routerLinkMarkets">markets overview</a> and
a professional data provider.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I start with a trial version?</mat-card-title
></mat-card-header
>
<mat-card-content
>Yes, you can try
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> by signing
up for Ghostfolio and applying for a trial (see “My Ghostfolio”). It
is easy, free and there is no commitment. You can stop using it at any
time.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How can I get a student discount for Ghostfolio
Premium?</mat-card-title
>
</mat-card-header>
<mat-card-content
>Request your student discount
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a> with
your university e-mail address.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Does the Ghostfolio Premium subscription renew
automatically?</mat-card-title
>
</mat-card-header>
<mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
not include auto-renewal. Upon expiration, you can choose whether to
start a new subscription.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title>
</mat-card-header>
<mat-card-content
>Ghostfolio works in every modern web browser on smartphones, tablets
and desktop computers. For <i>Android</i> users, there is a dedicated
Ghostfolio app available in the
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
>Google Play Store</a
>.</mat-card-content
>
</mat-card>
<mat-card
*ngIf="user?.subscription?.type === 'Premium'"
appearance="outlined"
class="mb-3"
>
<mat-card-header>
<mat-card-title
>I cannot find my broker in the list of platforms. What can I
do?</mat-card-title
>
</mat-card-header>
<mat-card-content>
Please send an e-mail with the web address of your broker to
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> and we are
happy to add it.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
>
</mat-card-header>
<mat-card-content
>Any support for Ghostfolio is welcome. Be it with a
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a>
subscription to finance the hosting infrastructure, a positive rating
in the
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
>Google Play Store</a
>, a star on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
feedback, bug reports, feature requests and of course contributions!
You can reach us via Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community,
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>,
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi&#64;ghostfol.io</a
></ng-container
>
or
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>
</mat-card-header>
<mat-card-content
>Please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack </a
>community, post to
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi&#64;ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
</div>
</div>
</div>
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs">
<a
#rla="routerLinkActive"
*ngIf="tab.showCondition !== false"
class="no-min-width px-3"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
/>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>
</nav>

View File

@ -1,13 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { FaqPageRoutingModule } from './faq-page-routing.module';
import { FaqPageComponent } from './faq-page.component';
@NgModule({
declarations: [FaqPageComponent],
imports: [CommonModule, FaqPageRoutingModule, MatCardModule],
imports: [CommonModule, FaqPageRoutingModule, MatTabsModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FaqPageModule {}

View File

@ -1,12 +1,7 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { FaqOverviewPageComponent } from './faq-overview-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FaqOverviewPageComponent,
path: '',
title: $localize`Frequently Asked Questions (FAQ)`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FaqOverviewPageRoutingModule {}

View File

@ -0,0 +1,40 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-faq-overview-page',
styleUrls: ['./faq-overview-page.scss'],
templateUrl: './faq-overview-page.html'
})
export class FaqOverviewPageComponent implements OnDestroy {
public routerLinkFeatures = ['/' + $localize`features`];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,172 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Frequently Asked Questions (FAQ)
</h1>
<p>
Find quick answers to commonly asked questions about Ghostfolio in our
Frequently Asked Questions (FAQ) section. Discover what Ghostfolio is,
explore its features, and learn about our privacy practices. Get all the
information you need in one place.
</p>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>What is Ghostfolio?</mat-card-title>
</mat-card-header>
<mat-card-content>
Ghostfolio is a lightweight, open source wealth management application
for individuals to keep track of their net worth. The software
empowers you to make solid, data-driven investment decisions.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What assets can I track with Ghostfolio?</mat-card-title
>
</mat-card-header>
<mat-card-content>
With Ghostfolio, you can keep track of various assets like stocks,
ETFs, bonds, cryptocurrencies and commodities.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What else is included in Ghostfolio?</mat-card-title
></mat-card-header
>
<mat-card-content>
Please find a feature overview to manage your wealth
<a [routerLink]="routerLinkFeatures">here</a>.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I use Ghostfolio anonymously?</mat-card-title
></mat-card-header
>
<mat-card-content>
Yes, the authentication system via security token enables you to sign
in securely and anonymously to Ghostfolio. There is no need for an
e-mail address, phone number, or a username.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How can Ghostfolio be free?</mat-card-title
></mat-card-header
>
<mat-card-content
>This project is driven by the efforts of contributors from around the
world. The
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
fully available as open source software (OSS). Thanks to our generous
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a> users
and <a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we
have the ability to run a free, limited plan for novice
investors.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Do you monetize or sell my financial data?</mat-card-title
></mat-card-header
>
<mat-card-content
>No, we value your privacy. We do not sell or share your financial
data with any third parties.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is your business model?</mat-card-title
></mat-card-header
>
<mat-card-content
>By offering
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a
subscription plan with a managed hosting service and enhanced
features, we fund our business while providing added value to our
users.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
>
</mat-card-header>
<mat-card-content
>Any support for Ghostfolio is welcome. Be it with a
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>
subscription to finance the hosting infrastructure, a positive rating
in the
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
>Google Play Store</a
>, a star on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
feedback, bug reports, feature requests and of course contributions!
You can reach us via Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community,
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>,
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi&#64;ghostfol.io</a
></ng-container
>
or
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>
</mat-card-header>
<mat-card-content
>Please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack </a
>community, post to
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi&#64;ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { FaqOverviewPageRoutingModule } from './faq-overview-page-routing.module';
import { FaqOverviewPageComponent } from './faq-overview-page.component';
@NgModule({
declarations: [FaqOverviewPageComponent],
imports: [CommonModule, FaqOverviewPageRoutingModule, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FaqOverviewPageModule {}

View File

@ -0,0 +1,12 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}

View File

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SaasPageComponent } from './saas-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: SaasPageComponent,
path: '',
title: $localize`Cloud` + ' (SaaS) ' + $localize`FAQ`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SaasPageRoutingModule {}

View File

@ -0,0 +1,42 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-saas-page',
styleUrls: ['./saas-page.scss'],
templateUrl: './saas-page.html'
})
export class SaasPageComponent implements OnDestroy {
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,162 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Frequently Asked Questions (FAQ)
</h1>
<p>
Find quick answers to commonly asked questions about the fully managed
Ghostfolio cloud offering in our Frequently Asked Questions (FAQ)
section.
</p>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I start?</mat-card-title>
</mat-card-header>
<mat-card-content>
You can sign up via the “<a [routerLink]="routerLinkRegister"
>Get Started</a
>” button at the top of the page. You have multiple options to join
Ghostfolio: Create an account with a security token or
<i>Google Sign</i>. We will guide you to set up your portfolio.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Will you spam me with emails once I sign up?</mat-card-title
></mat-card-header
>
<mat-card-content>
No, we do not even collect your email address, so you will not receive
any spam emails from us.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Is it really free?</mat-card-title></mat-card-header
>
<mat-card-content
>Yes, it is! Our
<a [routerLink]="routerLinkPricing">pricing page</a> details
everything you get for free.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is Ghostfolio Premium?</mat-card-title
></mat-card-header
>
<mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully
managed Ghostfolio cloud offering for ambitious investors. Revenue is
used to cover the costs of the hosting infrastructure and to fund
ongoing development. It is the Open Source code base with some extras
like the <a [routerLink]="routerLinkMarkets">markets overview</a> and
a professional data provider.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I start with a trial version?</mat-card-title
></mat-card-header
>
<mat-card-content
>Yes, you can try
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> by signing
up for Ghostfolio and applying for a trial (see “My Ghostfolio”). It
is easy, free and there is no commitment. You can stop using it at any
time.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How can I get a student discount for Ghostfolio
Premium?</mat-card-title
>
</mat-card-header>
<mat-card-content
>Request your student discount
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a> with
your university e-mail address.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Does the Ghostfolio Premium subscription renew
automatically?</mat-card-title
>
</mat-card-header>
<mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
not include auto-renewal. Upon expiration, you can choose whether to
start a new subscription.</mat-card-content
>
</mat-card>
<mat-card
*ngIf="user?.subscription?.type === 'Premium'"
appearance="outlined"
class="mb-3"
>
<mat-card-header>
<mat-card-title
>I cannot find my broker in the list of platforms. What can I
do?</mat-card-title
>
</mat-card-header>
<mat-card-content>
Please send an e-mail with the web address of your broker to
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> and we are
happy to add it.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title>
</mat-card-header>
<mat-card-content
>Ghostfolio works in every modern web browser on smartphones, tablets
and desktop computers. For <i>Android</i> users of the managed cloud
offering, there is a dedicated Ghostfolio app available in the
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
>Google Play Store</a
>.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>
</mat-card-header>
<mat-card-content
>Please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack </a
>community, post to
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi&#64;ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { SaasPageRoutingModule } from './saas-page-routing.module';
import { SaasPageComponent } from './saas-page.component';
@NgModule({
declarations: [SaasPageComponent],
imports: [CommonModule, MatCardModule, SaasPageRoutingModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SaasPageModule {}

View File

@ -0,0 +1,12 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}

View File

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SelfHostingPageComponent } from './self-hosting-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: SelfHostingPageComponent,
path: '',
title: $localize`Self-Hosting` + ' ' + $localize`FAQ`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SelfHostingPageRoutingModule {}

View File

@ -0,0 +1,21 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-self-hosting-page',
styleUrls: ['./self-hosting-page.scss'],
templateUrl: './self-hosting-page.html'
})
export class SelfHostingPageComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,56 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Frequently Asked Questions (FAQ)
</h1>
<p>
Find quick answers to commonly asked questions about self-hosting
Ghostfolio in our Frequently Asked Questions (FAQ) section.
</p>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I start?</mat-card-title>
</mat-card-header>
<mat-card-content>
If you prefer to run Ghostfolio on your own infrastructure, please
find the source code and further instructions on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title>
</mat-card-header>
<mat-card-content
>Ghostfolio works in every modern web browser on smartphones, tablets
and desktop computers.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>
</mat-card-header>
<mat-card-content
>Please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack </a
>community, post to
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content
>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { SelfHostingPageRoutingModule } from './self-hosting-page-routing.module';
import { SelfHostingPageComponent } from './self-hosting-page.component';
@NgModule({
declarations: [SelfHostingPageComponent],
imports: [CommonModule, MatCardModule, SelfHostingPageRoutingModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SelfHostingPageModule {}

View File

@ -0,0 +1,12 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}

View File

@ -274,7 +274,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
});
}
public openUpdateActivityDialog(activity: Activity): void {
public openUpdateActivityDialog(activity: Activity) {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: {
activity,
@ -311,7 +311,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openCreateActivityDialog(aActivity?: Activity): void {
private openCreateActivityDialog(aActivity?: Activity) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -150,7 +150,7 @@ export class ImportActivitiesDialog implements OnDestroy {
}: {
files: FileList;
stepper: MatStepper;
}): void {
}) {
if (files.length === 0) {
return;
}

View File

@ -24,84 +24,83 @@
>
</ng-template>
<div class="pt-3">
<ng-container *ngIf="mode === 'DIVIDEND'; else selectFile">
<form
[formGroup]="uniqueAssetForm"
(ngSubmit)="onLoadDividends(stepper)"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-select-trigger
>{{ uniqueAssetForm.controls['uniqueAsset']?.value?.name
}}</mat-select-trigger
>
<mat-option
*ngFor="let holding of holdings"
class="line-height-1"
[value]="{ dataSource: holding.dataSource, name: holding.name, symbol: holding.symbol }"
>
<span><b>{{ holding.name }}</b></span>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} · {{ holding.currency
}}</small
>
</mat-option>
</mat-select>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
/>
</mat-form-field>
<div class="d-flex flex-column justify-content-center">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!uniqueAssetForm.valid"
@if (mode === 'DIVIDEND') {
<form
[formGroup]="uniqueAssetForm"
(ngSubmit)="onLoadDividends(stepper)"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-select-trigger
>{{ uniqueAssetForm.controls['uniqueAsset']?.value?.name
}}</mat-select-trigger
>
<span i18n>Load Dividends</span>
</button>
</div>
</form>
</ng-container>
<ng-template #selectFile>
<mat-option
*ngFor="let holding of holdings"
class="line-height-1"
[value]="{ dataSource: holding.dataSource, name: holding.name, symbol: holding.symbol }"
>
<span><b>{{ holding.name }}</b></span>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} · {{ holding.currency
}}</small
>
</mat-option>
</mat-select>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
/>
</mat-form-field>
<div class="d-flex flex-column justify-content-center">
<button
class="drop-area p-4 text-center text-muted"
gfFileDrop
(click)="onSelectFile(stepper)"
(filesDropped)="onFilesDropped({stepper, files: $event})"
color="primary"
mat-flat-button
type="submit"
[disabled]="!uniqueAssetForm.valid"
>
<div
class="align-items-center d-flex flex-column justify-content-center"
>
<ion-icon class="cloud-icon" name="cloud-upload-outline" />
<span i18n>Choose or drop a file here</span>
</div>
<span i18n>Load Dividends</span>
</button>
<p class="mb-0 mt-3 text-center">
<small>
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</small>
</p>
</div>
</ng-template>
</form>
} @else {
<div class="d-flex flex-column justify-content-center">
<button
class="drop-area p-4 text-center text-muted"
gfFileDrop
(click)="onSelectFile(stepper)"
(filesDropped)="onFilesDropped({stepper, files: $event})"
>
<div
class="align-items-center d-flex flex-column justify-content-center"
>
<ion-icon class="cloud-icon" name="cloud-upload-outline" />
<span i18n>Choose or drop a file here</span>
</div>
</button>
<p class="mb-0 mt-3 text-center">
<small>
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</small>
</p>
</div>
}
</div>
</mat-step>
@ -115,79 +114,77 @@
>
</ng-template>
<div class="pt-3">
<ng-container *ngIf="errorMessages?.length === 0; else errorMessage">
<gf-activities-table
*ngIf="importStep === 1"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[pageSize]="maxSafeInteger"
[showActions]="false"
[showCheckbox]="true"
[showFooter]="false"
[showSymbolColumn]="false"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="totalItems"
(selectedActivities)="updateSelection($event)"
/>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="!selectedActivities?.length"
(click)="onImportActivities()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</ng-container>
<ng-template #errorMessage>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline" />
</div>
<div>{{ message }}</div>
@if(errorMessages?.length === 0) {
<gf-activities-table
*ngIf="importStep === 1"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[pageSize]="maxSafeInteger"
[showActions]="false"
[showCheckbox]="true"
[showSymbolColumn]="false"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="totalItems"
(selectedActivities)="updateSelection($event)"
/>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="!selectedActivities?.length"
(click)="onImportActivities()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
} @else {
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline" />
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="true"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</ng-template>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="true"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
}
</div>
</mat-step>
</mat-stepper>

View File

@ -46,7 +46,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean;
public isLoadingDividendTimelineChart: boolean;
public isLoadingInvestmentChart: boolean;
public isLoadingInvestmentTimelineChart: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' },
@ -154,6 +156,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}
private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
this.dataService
.fetchDividends({
filters: this.userService.getFilters(),
@ -164,6 +169,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe(({ dividends }) => {
this.dividendsByGroup = dividends;
this.isLoadingDividendTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
@ -194,6 +201,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
? translate('MONTH')
: translate('MONTHS');
this.isLoadingInvestmentTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -294,6 +294,7 @@
[daysInMarket]="daysInMarket"
[groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
[savingsRate]="savingsRate"
@ -331,6 +332,7 @@
[daysInMarket]="daysInMarket"
[groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
/>

View File

@ -48,11 +48,9 @@ export class FirePageComponent implements OnDestroy, OnInit {
.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => {
if (summary.cash === null || summary.currentValue === null) {
return;
}
this.fireWealth = new Big(summary.fireWealth);
this.fireWealth = summary.fireWealth
? new Big(summary.fireWealth)
: new Big(10000);
this.withdrawalRatePerYear = this.fireWealth.mul(4).div(100);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
@ -94,10 +92,13 @@ export class FirePageComponent implements OnDestroy, OnInit {
permissions.createOrder
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
? false
: hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}

View File

@ -18,6 +18,10 @@
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[ngStyle]="{
opacity: user?.subscription?.type === 'Basic' ? '0.67' : 'initial',
'pointer-events': user?.subscription?.type === 'Basic' ? 'none' : 'initial'
}"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate"
@ -54,7 +58,11 @@
}"
/>
</div>
<div *ngIf="!isLoading" i18n>
<div
*ngIf="!isLoading"
i18n
[ngClass]="{ 'text-muted': user?.subscription?.type === 'Basic' }"
>
If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value

View File

@ -132,6 +132,7 @@
<div class="col-lg">
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"

View File

@ -79,7 +79,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
accessToken: string,
authToken: string,
role: Role
): void {
) {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,

View File

@ -18,7 +18,7 @@ export class SettingsStorageService {
window.localStorage.setItem(aKey, aValue);
}
public removeSetting(aKey: string): void {
public removeSetting(aKey: string) {
return window.localStorage.removeItem(aKey);
}
}

View File

@ -21,14 +21,14 @@ export class TokenStorageService {
);
}
public saveToken(token: string, staySignedIn = false): void {
public saveToken(token: string, staySignedIn = false) {
if (staySignedIn) {
window.localStorage.setItem(KEY_TOKEN, token);
}
window.sessionStorage.setItem(KEY_TOKEN, token);
}
public signOut(): void {
public signOut() {
const utmSource = window.localStorage.getItem('utm_source');
if (this.webAuthnService.isEnabled()) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ export interface AdminMarketData {
}
export interface AdminMarketDataItem {
activitiesCount?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countriesCount: number;

View File

@ -72,7 +72,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
}
}
public onAddFilter({ input, value }: MatChipInputEvent): void {
public onAddFilter({ input, value }: MatChipInputEvent) {
if (value?.trim()) {
this.updateFilters();
}
@ -85,7 +85,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
this.searchControl.setValue(undefined);
}
public onRemoveFilter(aFilter: Filter): void {
public onRemoveFilter(aFilter: Filter) {
this.selectedFilters = this.selectedFilters.filter((filter) => {
return filter.id !== aFilter.id;
});
@ -93,7 +93,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
this.updateFilters();
}
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
public onSelectFilter(event: MatAutocompleteSelectedEvent) {
this.selectedFilters.push(
this.allFilters.find((filter) => {
return filter.id === event.option.value;

View File

@ -45,7 +45,6 @@ export class ActivitiesTableComponent
@Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions = true;
@Input() showCheckbox = false;
@Input() showFooter = true;
@Input() showNameColumn = true;
@Input() sortColumn: string;
@Input() sortDirection: SortDirection;
@ -217,7 +216,7 @@ export class ActivitiesTableComponent
alert(aComment);
}
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset) {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});

View File

@ -10,7 +10,7 @@ export class CarouselItem implements FocusableOption {
public constructor(readonly element: ElementRef<HTMLElement>) {}
public focus(): void {
public focus() {
this.element.nativeElement.focus({ preventScroll: true });
}
}

View File

@ -13,7 +13,7 @@
.mdc-text-field--disabled {
.mdc-floating-label,
.mdc-text-field__input {
color: inherit;
color: inherit !important;
}
.mdc-notched-outline__leading,

View File

@ -137,12 +137,11 @@
mat-row
[ngClass]="{
'cursor-pointer':
hasPermissionToShowValues &&
hasPermissionToOpenDetails &&
!ignoreAssetSubClasses.includes(row.assetSubClass)
}"
(click)="
hasPermissionToShowValues &&
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
onOpenPositionDialog({
dataSource: row.dataSource,
symbol: row.symbol

View File

@ -26,6 +26,7 @@ export class HoldingsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToOpenDetails = true;
@Input() hasPermissionToShowValues = true;
@Input() holdings: PortfolioPosition[];
@Input() locale: string;
@ -68,10 +69,12 @@ export class HoldingsTableComponent implements OnChanges, OnDestroy, OnInit {
}
}
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset) {
if (this.hasPermissionToOpenDetails) {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }
});
}
}
public onShowAllPositions() {

View File

@ -134,31 +134,31 @@ export abstract class AbstractMatFormField<T>
return this.focused || !this.empty;
}
public ngDoCheck(): void {
public ngDoCheck() {
if (this.ngControl) {
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public ngOnDestroy(): void {
public ngOnDestroy() {
this.stateChanges.complete();
this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
}
public registerOnChange(fn: (_: T) => void): void {
public registerOnChange(fn: (_: T) => void) {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
public registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
public setDescribedByIds(ids: string[]): void {
public setDescribedByIds(ids: string[]) {
this.describedBy = ids.join(' ');
}
public writeValue(value: T): void {
public writeValue(value: T) {
this.value = value;
}
@ -173,7 +173,7 @@ export abstract class AbstractMatFormField<T>
this.stateChanges.next();
}
public onContainerClick(): void {
public onContainerClick() {
if (!this.focused) {
this.focus();
}

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.51.0",
"version": "2.53.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -116,7 +116,7 @@
"lodash": "4.17.21",
"marked": "9.1.6",
"ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "2.9.1",
"ng-extract-i18n-merge": "2.10.0",
"ngx-device-detector": "5.0.1",
"ngx-markdown": "17.1.1",
"ngx-skeleton-loader": "7.0.0",

View File

@ -15117,10 +15117,10 @@ neo-async@^2.5.0, neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
ng-extract-i18n-merge@2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/ng-extract-i18n-merge/-/ng-extract-i18n-merge-2.9.1.tgz#f8e4431a8672d27be6508c72f3cf47a318b1ecab"
integrity sha512-EJAgJrV2ZSRoH1njMI9lLLtLJkwabkk41ZZyV+U+6h8e5vDCM4zPGjm0NNZFy+YP+/ST+nlvi2CxprDXnjS8BQ==
ng-extract-i18n-merge@2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/ng-extract-i18n-merge/-/ng-extract-i18n-merge-2.10.0.tgz#20d2a4a1d21f058773242cbcc8406c4ef0f8fea0"
integrity sha512-mWYRWAUc7kirS3kIQxUR0kGv7Yv5JnV0C05VNvGwHdyMM3vSdJ0WAE/o4RwzW1cRyzXuG9oNOz4gctTzQsTErw==
dependencies:
"@angular-devkit/architect" "^0.1301.0 || ^0.1401.0 || ^0.1501.0 || ^0.1601.0 || ^0.1700.0"
"@angular-devkit/core" "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"