Compare commits

...

51 Commits

Author SHA1 Message Date
7d34fba7c1 Release 1.287.0 (#2136) 2023-07-09 10:44:41 +02:00
c434b730a8 Feature/hide average buy price in position detail chart if no holding (#2133)
* Hide the average buy price if no holding

* Update changelog
2023-07-09 10:42:53 +02:00
2d23c566f1 Feature/setup personal finance tools pages (#2135) 2023-07-09 10:42:10 +02:00
ba220eaee9 Bugfix/fix sorting by currency in activities table (#2122)
* Fix sorting by currency

* Update changelog
2023-07-09 09:38:48 +02:00
09023214ce Feature/French translation update (#2130)
* French translation update

* Update changelog
2023-07-07 21:26:51 +02:00
1ceabb6e6b Feature/refactor blog articles to standalone components (#2117)
* Refactor blog articles to standalone components

* Update changelog
2023-07-04 18:42:40 +02:00
421072c7fa Release 1.286.0 (#2120) 2023-07-03 22:30:33 +02:00
0d421e7181 Bugfix/fix adding 'Item' and 'Liability' activities (#2119)
* Fix adding activities of type item and liability

* Update changelog
2023-07-03 22:29:00 +02:00
f5180ce88f Improve wording (#2118) 2023-07-03 10:10:08 +02:00
aabf27dc96 Remove empty style files (#2116) 2023-07-02 08:17:39 +02:00
421809ae95 Release 1.285.0 (#2114) 2023-07-01 19:14:26 +02:00
d3234f9e77 Feature/add blog post exploring the path to fire (#2113)
* Add blog post: Exploring the Path to FIRE

* Update changelog
2023-07-01 18:33:31 +02:00
a40be2f744 Feature/extract locales 20230701 (#2112)
* Improve i18n

* Update changelog
2023-07-01 18:30:09 +02:00
e62da06c5c Feature/extend scraper configuration support (#2110)
* Extend scraper configuration support

* Update changelog
2023-07-01 11:08:10 +02:00
b7f635bdfc Increase frequency (#2111) 2023-07-01 11:06:34 +02:00
0a465f125d Feature/add pagination to market data table in admin control panel (#2108)
* Add pagination

* Update changelog
2023-07-01 10:49:00 +02:00
c02e390bc1 Rename Slack channel to community (#2091) 2023-06-28 15:59:38 +02:00
f9bec0d793 Release 1.284.0 (#2106) 2023-06-27 18:47:44 +02:00
2f44748f79 Feature/upgrade internet identity dependencies to version 0.15.7 (#2105)
* Disable crossOriginOpenerPolicy

* Upgrade Internet Identity dependencies

* Update changelog
2023-06-27 18:46:03 +02:00
97504756be Feature/add currency to cash balance in create or update account dialog (#2104)
* Add currency as text suffix to cash balance

* Update changelog
2023-06-27 18:33:50 +02:00
6a802a62a0 Add ability to search for indices and fix gf-symbol-autocomplete validation (#2094)
* Bugfix/Fix gf-symbol-autocomplete validation

* Feature/Add ability to search for indices

* Update changelog
2023-06-26 18:38:24 +02:00
51ca26bb4d Release 1.283.5 (#2103) 2023-06-25 13:39:39 +02:00
2ecc8dbc4e Release 1.283.4 (#2101) 2023-06-24 18:41:37 +02:00
c0e0e2401e Release 1.283.3 (#2100) 2023-06-24 18:25:44 +02:00
1a30c180bc Release 1.283.2 (#2099) 2023-06-24 18:05:12 +02:00
39d4f80f36 Release 1.283.1 (#2098) 2023-06-24 17:49:12 +02:00
3693091ad6 Release 1.283.0 (#2097) 2023-06-24 17:14:04 +02:00
bf52f1137d Feature/setup helmet (#2096)
* Setup helmet

* Update changelog
2023-06-24 17:12:05 +02:00
54ea6c84b4 Feature/add caching for quotes (#2095)
* Add caching for quotes

* Update changelog
2023-06-24 13:06:28 +02:00
689e50ae1a Improve bug report template (#2092)
* Update Slack community link
* Remove checkboxes
2023-06-23 08:52:24 +02:00
677757fdf0 Feature/improve import dividends dialog (#2086)
* Improve dialog

* Add loading indicator
* Improve selected item of holding selector

* Update changelog
2023-06-22 20:29:50 +02:00
58d9816f01 Feature/extend symbol search component by asset sub classes (#2087)
* Add asset sub classes

* Update changelog
2023-06-21 16:09:18 +02:00
5f3d445f1d Release 1.282.0 (#2085) 2023-06-19 20:58:30 +02:00
fce6caebc2 Fix arm64 prisma binary target (#2082)
* Fix arm64 prisma binary target

* Update changelog
2023-06-19 20:56:28 +02:00
d0a4f5c000 Feature/added ability to add asset profile in admin control panel (#2075)
* Added ability to add asset profile in admin control panel

* Update changelog
2023-06-19 20:50:11 +02:00
b5e2a3aa91 Feature/harmonize use of permissions on about and landing page (#2084)
* Harmonize use of permissions

* About page
* Landing page

* About changelog
2023-06-19 20:29:36 +02:00
f47883fb0b Feature/add icon to external links in footer (#2083)
* Add icon for external links

* Update changelog
2023-06-19 20:29:12 +02:00
2932744a68 Feature/improve language localization for german 20230618 (#2081)
* Update translations

* Update changelog
2023-06-19 19:40:10 +02:00
73c0f02e06 Add the final translations for Portuguese (#2079) 2023-06-18 08:59:16 +02:00
382fe24f29 Release 1.281.0 (#2080) 2023-06-17 17:38:30 +02:00
908876ca6e Feature/setup language localization for portuguese (#2076)
* Set up Portuguese

* Update changelog
2023-06-17 17:26:40 +02:00
99cf9f8802 Feature/translation pt 2 (#2074)
* Add more Portuguese translations
2023-06-16 20:47:06 +02:00
7444ff97fc Feature/translation pt (#2073)
* Complete Portuguese translations for home screen and various other Portuguese translations
2023-06-15 08:34:47 +02:00
834a48466e Feature/add liabilities to feature page (#2072)
* Add section for liabilities

* Update changelog
2023-06-14 20:08:04 +02:00
a9526430c2 Improve column headers in holdings table for mobile (#2071)
* Improve column headers in holdings table for mobile

* Update changelog
2023-06-13 21:00:56 +02:00
fce3b2084e Feature/extract symbol search to component (#2003) (#2056)
* Extract symbol search to component (#2003)

* Update changelog
2023-06-13 20:36:16 +02:00
f5a50a95de Feature/upgrade prisma to version 4.15.0 (#2070)
* Upgrade prisma to version 4.15.0

* Update changelog
2023-06-12 15:16:43 +02:00
06dfb91f82 Release 1.280.1 (#2069) 2023-06-10 21:41:39 +02:00
be36050d76 Release 1.280.0 (#2068) 2023-06-10 17:18:28 +02:00
7931e6950d Feature/add support for liabilities (#1789)
* Add support for liabilities

* Update changelog
2023-06-10 16:17:11 +02:00
04eb452e04 Add missing guards (#2067) 2023-06-10 16:16:27 +02:00
183 changed files with 6497 additions and 3293 deletions

View File

@ -6,7 +6,7 @@ labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Bug Description**
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information -->
- [ ] Cloud
- [ ] Self-hosted
- Cloud or Self-hosted
- Ghostfolio Version X.Y.Z
- Browser
- OS

View File

@ -5,6 +5,99 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.287.0 - 2023-07-09
### Changed
- Hid the average buy price in the position detail chart if there is no holding
- Improved the language localization for French (`fr`)
- Refactored the blog articles to standalone components
### Fixed
- Fixed the sorting by currency in the activities table
## 1.286.0 - 2023-07-03
### Fixed
- Fixed the creation of (wealth) items and liabilities
## 1.285.0 - 2023-07-01
### Added
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
- Added pagination to the historical market data table of the admin control panel
- Added the attribute `headers` to the scraper configuration
### Changed
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
- Improved the language localization for German (`de`)
## 1.284.0 - 2023-06-27
### Added
- Added the currency to the cash balance in the create or update account dialog
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
### Changed
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
### Fixed
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
## 1.283.5 - 2023-06-25
### Added
- Added the caching for current market prices
- Added a loading indicator to the import dividends dialog
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
### Changed
- Improved the selected item of the holding selector in the import dividends dialog
- Extended the symbol search component by asset sub classes
## 1.282.0 - 2023-06-19
### Added
- Added an icon to the external links in the footer navigation
- Added the ability to add an asset profile in the admin control panel
### Changed
- Harmonized the use of permissions on the about page
- Harmonized the use of permissions on the landing page
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
## 1.281.0 - 2023-06-17
### Added
- Extended the feature overview page by liabilities
- Set up the language localization for Portuguese (`pt`)
### Changed
- Extracted the symbol search to a dedicated component
- Improved the column headers in the holdings table for mobile
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
## 1.280.1 - 2023-06-10
### Added
- Added support for liabilities
## 1.279.0 - 2023-06-10
### Added
@ -793,7 +886,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed
@ -1073,7 +1165,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support to change the appearance (dark mode) in user settings
- Added the total amount chart to the investment timeline
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
### Changed

View File

@ -1,7 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
@ -26,11 +28,12 @@ import {
Post,
Put,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -245,7 +248,11 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
if (
!hasPermission(
@ -270,7 +277,13 @@ export class AdminController {
})
];
return this.adminService.getMarketData(filters);
return this.adminService.getMarketData({
filters,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@Get('market-data/:dataSource/:symbol')
@ -328,6 +341,28 @@ export class AdminController {
});
}
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ dataSource, symbol });
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteProfileData(

View File

@ -1,21 +1,24 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
DEFAULT_PAGE_SIZE,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@ -25,6 +28,7 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -35,6 +39,38 @@ export class AdminService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async addAssetProfile({
dataSource,
symbol
}: UniqueAsset): Promise<SymbolProfile | never> {
try {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException(
`Asset profile of ${symbol} (${dataSource}) already exists`
);
}
throw error;
}
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
@ -65,7 +101,21 @@ export class AdminService {
};
}
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
public async getMarketData({
filters,
sortColumn,
sortDirection,
skip,
take = DEFAULT_PAGE_SIZE
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
@ -75,42 +125,33 @@ export class AdminService {
}
);
const marketData = await this.prismaService.marketData.groupBy({
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
}
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
Order: {
_count: sortDirection
}
};
}
}
const [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
@ -129,38 +170,48 @@ export class AdminService {
sectors: true,
symbol: true
}
})
).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return {
countriesCount,
marketDataItemCount,
sectorsCount,
activitiesCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
}),
this.prismaService.symbolProfile.count({ where })
]);
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
count,
marketData: assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
dataSource,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
)
};
}
@ -198,12 +249,14 @@ export class AdminService {
public async patchAssetProfileData({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
});

View File

@ -1,3 +1,4 @@
import { Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto {
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
comment?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsObject()
@IsOptional()
symbolMapping?: {

View File

@ -104,6 +104,11 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
}
if (

View File

@ -202,7 +202,7 @@ export class ImportService {
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =

View File

@ -96,7 +96,7 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId;
if (data.type === 'ITEM') {
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -129,7 +129,10 @@ export class OrderService {
}
});
const isDraft = isAfter(data.date as Date, endOfToday());
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
@ -201,7 +204,7 @@ export class OrderService {
where
});
if (order.type === 'ITEM') {
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
@ -320,7 +323,11 @@ export class OrderService {
})
)
.filter((order) => {
return withExcludedAccounts || order.Account?.isExcluded === false;
return (
withExcludedAccounts ||
!order.Account ||
order.Account?.isExcluded === false
);
})
.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
@ -368,7 +375,7 @@ export class OrderService {
let isDraft = false;
if (data.type === 'ITEM') {
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
delete data.SymbolProfile.connect;
} else {
delete data.SymbolProfile.update;

View File

@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
[],
null,
null,
propertyService
propertyService,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -162,6 +162,7 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'

View File

@ -1302,12 +1302,11 @@ export class PortfolioService {
}: {
activities: OrderWithAccount[];
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date and type dividend
// Filter out all activities before given date (drafts) and type dividend
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
@ -1431,7 +1430,7 @@ export class PortfolioService {
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date
// Filter out all activities before given date (drafts)
return isBefore(date, new Date(activity.date));
})
.map(({ fee, SymbolProfile }) => {
@ -1478,19 +1477,37 @@ export class PortfolioService {
};
}
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type item
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type item
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.ITEM
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.ITEM
);
})
.map((order) => {
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities(activities: OrderWithAccount[]) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
@ -1601,6 +1618,7 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1633,6 +1651,7 @@ export class PortfolioService {
.plus(performanceInformation.performance.currentValue)
.plus(items)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -1659,6 +1678,7 @@ export class PortfolioService {
fees,
firstOrderDate,
items,
liabilities,
netWorth,
totalBuy,
totalSell,

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@ -13,6 +14,10 @@ export class RedisCacheService {
return await this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`;
}
public async remove(key: string) {
await this.cache.del(key);
}

View File

@ -36,10 +36,12 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
@Query('includeIndices') includeIndices: boolean = false,
@Query('query') query = ''
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup({
includeIndices,
query: query.toLowerCase(),
user: this.request.user
});

View File

@ -81,9 +81,11 @@ export class SymbolService {
}
public async lookup({
includeIndices = false,
query,
user
}: {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
@ -95,6 +97,7 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search({
includeIndices,
query,
user
});

View File

@ -166,7 +166,7 @@ export class UserService {
this.subscriptionService.getSubscription(Subscription);
if (
Analytics?.activityCount % 20 === 0 &&
Analytics?.activityCount % 10 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);

View File

@ -1,7 +1,9 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import * as bodyParser from 'body-parser';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -10,11 +12,12 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',
@ -32,6 +35,22 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})
);
}
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;

View File

@ -16,6 +16,7 @@ export class ConfigurationService {
default: 'USD'
}),
BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),

View File

@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(query);
return {
items: result?.bestMatches?.map((bestMatch) => {

View File

@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
const { coins } = await get();
items = coins.map(({ id: symbol, name }) => {

View File

@ -1,3 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [

View File

@ -1,3 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -27,7 +28,8 @@ export class DataProviderService {
private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
@ -235,9 +237,43 @@ export class DataProviderService {
} = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
// Get items from cache
const itemsToFetch: IDataGatheringItem[] = [];
const promises = [];
for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
} catch {}
}
if (!quoteString) {
itemsToFetch.push({ dataSource, symbol });
}
}
const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) {
Logger.debug(
`Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3
)} seconds`
);
}
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
return dataSource;
});
const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
@ -271,6 +307,15 @@ export class DataProviderService {
result
)) {
response[symbol] = dataProviderResponse;
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource],
symbol
}),
JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
}
Logger.debug(
@ -283,7 +328,7 @@ export class DataProviderService {
);
try {
await this.marketDataService.updateMany({
this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (
@ -322,9 +367,11 @@ export class DataProviderService {
}
public async search({
includeIndices = false,
query,
user
}: {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
@ -347,7 +394,12 @@ export class DataProviderService {
}
for (const dataSource of dataSources) {
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
promises.push(
this.getDataProvider(DataSource[dataSource]).search({
includeIndices,
query
})
);
}
const searchResults = await Promise.all(promises);

View File

@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return !symbol.endsWith('.FOREX');
})
.map((symbol) => {
return this.search(symbol);
return this.search({ query: symbol });
})
);
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(aQuery);
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(query);
return {
items: searchResult

View File

@ -143,12 +143,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
'GET',
'json',
200

View File

@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
}
]

View File

@ -42,5 +42,11 @@ export interface DataProviderInterface {
getTestSymbol(): string;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
search({
includeIndices,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }>;
}

View File

@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[{ symbol, dataSource: this.getName() }]
);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
const {
defaultMarketPrice,
headers = {},
selector,
url
} = symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const get = bent(url, 'GET', 'string', 200, headers);
const html = await get();
const $ = cheerio.load(html);
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
}
]

View File

@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -275,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
try {
const searchResult = await yahooFinance.search(aQuery);
const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
if (includeIndices) {
quoteTypes.push('INDEX');
}
const searchResult = await yahooFinance.search(query);
const quotes = searchResult.quotes
.filter((quote) => {
@ -295,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.baseCurrency
)
)) ||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
quoteTypes.includes(quoteType)
);
})
.filter(({ quoteType, symbol }) => {

View File

@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string;

View File

@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
@ -90,11 +96,12 @@ export class SymbolProfileService {
public updateSymbolProfile({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({
data: { comment, symbolMapping },
data: { comment, scraperConfiguration, symbolMapping },
where: { dataSource_symbol: { dataSource, symbol } }
});
}
@ -189,6 +196,8 @@ export class SymbolProfileService {
if (scraperConfiguration) {
return {
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};

View File

@ -18,36 +18,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})),
...[
'about/changelog',
/////
'a-propos/changelog',
'informazioni-su/changelog',
'over/changelog',
'sobre/changelog',
'ueber-uns/changelog'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
})),
...[
'about/privacy-policy',
/////
'a-propos/politique-de-confidentialite',
'informazioni-su/informativa-sulla-privacy',
'over/privacybeleid',
'sobre/politica-de-privacidad',
'ueber-uns/datenschutzbestimmungen'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
})),
{
path: 'account',
loadChildren: () =>
@ -77,97 +47,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})),
{
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'blog/2022/10/hacktoberfest-2022',
loadChildren: () =>
import(
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
).then((m) => m.Hacktoberfest2022PageModule)
},
{
path: 'blog/2022/11/black-friday-2022',
loadChildren: () =>
import(
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule)
},
{
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
loadChildren: () =>
import(
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
},
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
},
{
path: 'demo',
loadChildren: () =>
@ -179,6 +58,7 @@ const routes: Routes = [
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
@ -243,6 +123,7 @@ const routes: Routes = [
'pricing',
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
@ -259,6 +140,7 @@ const routes: Routes = [
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'

View File

@ -89,7 +89,7 @@
<li>
<a i18n [routerLink]="['/about', 'license']">License</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a>
</li>
<li *ngIf="hasPermissionForSubscription">
@ -101,9 +101,13 @@
>
</li>
<li *ngIf="hasPermissionForSubscription">
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
>Status</a
>
<a
class="align-items-baseline d-flex"
href="https://status.ghostfol.io"
target="_blank"
title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
</ul>
</div>
@ -112,24 +116,30 @@
<ul class="list-unstyled">
<li>
<a
class="align-items-baseline d-flex"
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_"
target="_blank"
title="Follow Ghostfolio on Twitter"
>Twitter</a
>
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>&nbsp;</li>
<li>
@ -150,6 +160,9 @@
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
</ul>
</div>
</div>

View File

@ -33,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem;
@ -70,6 +71,11 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableSubscription
);
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
permissions.enableStatistics
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex

View File

@ -1,4 +1,5 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@ -7,23 +8,26 @@ import {
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource } from '@prisma/client';
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
export class AdminMarketDataComponent
implements AfterViewInit, OnDestroy, OnInit
{
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
@ -73,6 +80,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -80,7 +89,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -99,6 +107,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
dataSource: params['dataSource'],
symbol: params['symbol']
});
} else if (params['createAssetProfileDialog']) {
this.openCreateAssetProfileDialog();
}
});
@ -113,34 +123,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
);
}
});
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.subscribe((filters) => {
this.activeFilters = filters;
this.loadData();
});
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe(
({ active: sortColumn, direction }: Sort) => {
this.paginator.pageIndex = 0;
this.loadData({
sortColumn,
sortDirection: <Prisma.SortOrder>direction,
pageIndex: this.paginator.pageIndex
});
}
);
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
public onChangePage(page: PageEvent) {
this.loadData({
pageIndex: page.pageIndex,
sortColumn: this.sort.active,
sortDirection: <Prisma.SortOrder>this.sort.direction
});
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
@ -208,6 +224,47 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private loadData(
{
pageIndex,
sortColumn,
sortDirection
}: {
pageIndex: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
} = { pageIndex: 0 }
) {
this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
this.adminService
.fetchAdminMarketData({
sortColumn,
sortDirection,
filters: this.activeFilters,
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ count, marketData }) => {
this.totalItems = count;
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
private openAssetProfileDialog({
dataSource,
symbol
@ -241,4 +298,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
});
}
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
autoFocus: false,
data: <CreateAssetProfileDialogParams>{
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(
switchMap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
return this.adminService.fetchAdminMarketData({
filters: this.activeFilters,
take: this.pageSize
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

View File

@ -56,7 +56,7 @@
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -74,7 +74,7 @@
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -83,7 +83,7 @@
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -92,7 +92,7 @@
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -162,6 +162,40 @@
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
></tr>
</table>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none':
(isLoading && totalItems === 0) ||
totalItems <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<ngx-skeleton-loader
*ngIf="isLoading && totalItems === 0"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</div>
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@NgModule({
declarations: [AdminMarketDataComponent],
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
CommonModule,
GfActivitiesFilterModule,
GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -2,4 +2,11 @@
:host {
display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
}

View File

@ -13,6 +13,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
comment: '',
scraperConfiguration: '',
symbolMapping: ''
});
public assetSubClass: string;
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileForm.setValue({
comment: this.assetProfile?.comment ?? '',
scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {}
),
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
});
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public onSubmit() {
let scraperConfiguration = {};
let symbolMapping = {};
try {
scraperConfiguration = JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].value
);
} catch {}
try {
symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
scraperConfiguration,
symbolMapping,
comment: this.assetProfileForm.controls['comment'].value ?? null
};

View File

@ -162,6 +162,17 @@
></textarea>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Scraper Configuration</mat-label>
<textarea
cdkTextareaAutosize
formControlName="scraperConfiguration"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>

View File

@ -0,0 +1,53 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog',
templateUrl: 'create-asset-profile-dialog.html'
})
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup;
public constructor(
public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({
searchSymbol: new FormControl(null, [Validators.required])
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
this.dialogRef.close({
dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
});
}
public ngOnDestroy() {}
}

View File

@ -0,0 +1,28 @@
<form
class="d-flex flex-column h-100"
[formGroup]="createAssetProfileForm"
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
formControlName="searchSymbol"
[includeIndices]="true"
/>
</mat-form-field>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!createAssetProfileForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@NgModule({
declarations: [CreateAssetProfileDialog],
imports: [
CommonModule,
FormsModule,
GfSymbolAutocompleteModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCreateAssetProfileDialogModule {}

View File

@ -0,0 +1,4 @@
export interface CreateAssetProfileDialogParams {
deviceType: string;
locale: string;
}

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
private fetchAdminData() {
this.dataService
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -30,6 +31,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
private fetchAdminData() {
this.dataService
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {

View File

@ -194,6 +194,26 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end">
<span
*ngIf="summary?.liabilities || summary?.liabilities === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
<div class="justify-content-end">

View File

@ -215,6 +215,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.benchmarkDataItems[0].value = this.averagePrice;
}
this.benchmarkDataItems = this.benchmarkDataItems.map(
({ date, value }) => {
return {
date,
value: value === 0 ? null : value
};
}
);
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {

View File

@ -25,6 +25,7 @@ const routes: Routes = [
...[
'license',
/////
'licenca',
'licence',
'licencia',
'licentie',
@ -37,13 +38,22 @@ const routes: Routes = [
(m) => m.LicensePageModule
)
})),
{
path: 'privacy-policy',
...[
'privacy-policy',
/////
'datenschutzbestimmungen',
'informativa-sulla-privacy',
'politique-de-confidentialite',
'politica-de-privacidad',
'politica-de-privacidade',
'privacybeleid'
].map((path) => ({
path,
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
}
}))
],
component: AboutPageComponent,
path: '',

View File

@ -15,6 +15,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public user: User;
@ -34,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription

View File

@ -19,9 +19,11 @@
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we share aggregated
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
of the platforms performance. The project has been initiated by
<ng-container *ngIf="hasPermissionForStatistics">
and we share aggregated
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
of the platforms performance</ng-container
>. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>

View File

@ -156,10 +156,10 @@
>Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option
>
<!--<mat-option value="pt"
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
>)</mat-option
>-->
>
</mat-select>
</mat-form-field>
</div>

View File

@ -37,6 +37,7 @@
type="number"
[(ngModel)]="data.account.balance"
/>
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
</mat-form-field>
</div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HalloGhostfolioPageComponent,
path: '',
title: 'Hallo Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HalloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hallo-ghostfolio-page',
styleUrls: ['./hallo-ghostfolio-page.scss'],
standalone: true,
templateUrl: './hallo-ghostfolio-page.html'
})
export class HalloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HalloGhostfolioPageRoutingModule } from './hallo-ghostfolio-page-routing.module';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
@NgModule({
declarations: [HalloGhostfolioPageComponent],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HalloGhostfolioPageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HelloGhostfolioPageComponent,
path: '',
title: 'Hello Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HelloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hello-ghostfolio-page',
styleUrls: ['./hello-ghostfolio-page.scss'],
standalone: true,
templateUrl: './hello-ghostfolio-page.html'
})
export class HelloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HelloGhostfolioPageRoutingModule } from './hello-ghostfolio-page-routing.module';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
@NgModule({
declarations: [HelloGhostfolioPageComponent],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HelloGhostfolioPageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FirstMonthsInOpenSourcePageComponent,
path: '',
title: 'First months in Open Source'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirstMonthsInOpenSourceRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-first-months-in-open-source-page',
styleUrls: ['./first-months-in-open-source-page.scss'],
standalone: true,
templateUrl: './first-months-in-open-source-page.html'
})
export class FirstMonthsInOpenSourcePageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
@NgModule({
declarations: [FirstMonthsInOpenSourcePageComponent],
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirstMonthsInOpenSourcePageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsInternetIdentityPageComponent,
path: '',
title: 'Ghostfolio meets Internet Identity'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsInternetIdentityRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-meets-internet-identity-page',
styleUrls: ['./ghostfolio-meets-internet-identity-page.scss'],
standalone: true,
templateUrl: './ghostfolio-meets-internet-identity-page.html'
})
export class GhostfolioMeetsInternetIdentityPageComponent {}

View File

@ -1,17 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsInternetIdentityRoutingModule } from './ghostfolio-meets-internet-identity-page-routing.module';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
@NgModule({
declarations: [GhostfolioMeetsInternetIdentityPageComponent],
imports: [
CommonModule,
GhostfolioMeetsInternetIdentityRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsInternetIdentityPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HowDoIGetMyFinancesInOrderPageComponent,
path: '',
title: 'How do I get my finances in order?'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HowDoIGetMyFinancesInOrderRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-how-do-i-get-my-finances-in-order-page',
styleUrls: ['./how-do-i-get-my-finances-in-order-page.scss'],
standalone: true,
templateUrl: './how-do-i-get-my-finances-in-order-page.html'
})
export class HowDoIGetMyFinancesInOrderPageComponent {}

View File

@ -1,17 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HowDoIGetMyFinancesInOrderRoutingModule } from './how-do-i-get-my-finances-in-order-page-routing.module';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
@NgModule({
declarations: [HowDoIGetMyFinancesInOrderPageComponent],
imports: [
CommonModule,
HowDoIGetMyFinancesInOrderRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HowDoIGetMyFinancesInOrderPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FiveHundredStarsOnGitHubPageComponent,
path: '',
title: '500 Stars on GitHub'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FiveHundredStarsOnGitHubRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-500-stars-on-github-page',
styleUrls: ['./500-stars-on-github-page.scss'],
standalone: true,
templateUrl: './500-stars-on-github-page.html'
})
export class FiveHundredStarsOnGitHubPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FiveHundredStarsOnGitHubRoutingModule } from './500-stars-on-github-page-routing.module';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
@NgModule({
declarations: [FiveHundredStarsOnGitHubPageComponent],
imports: [CommonModule, FiveHundredStarsOnGitHubRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FiveHundredStarsOnGitHubPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: Hacktoberfest2022PageComponent,
path: '',
title: 'Hacktoberfest 2022'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class Hacktoberfest2022RoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2022-page',
styleUrls: ['./hacktoberfest-2022-page.scss'],
standalone: true,
templateUrl: './hacktoberfest-2022-page.html'
})
export class Hacktoberfest2022PageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Hacktoberfest2022RoutingModule } from './hacktoberfest-2022-page-routing.module';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
@NgModule({
declarations: [Hacktoberfest2022PageComponent],
imports: [CommonModule, Hacktoberfest2022RoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Hacktoberfest2022PageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: BlackFriday2022PageComponent,
path: '',
title: 'Black Friday 2022'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BlackFriday2022RoutingModule {}

View File

@ -1,9 +1,13 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
@Component({
host: { class: 'page' },
imports: [GfPremiumIndicatorModule, MatButtonModule, RouterModule],
selector: 'gf-black-friday-2022-page',
styleUrls: ['./black-friday-2022-page.scss'],
standalone: true,
templateUrl: './black-friday-2022-page.html'
})
export class BlackFriday2022PageComponent {

View File

@ -1,21 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { BlackFriday2022RoutingModule } from './black-friday-2022-page-routing.module';
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
@NgModule({
declarations: [BlackFriday2022PageComponent],
imports: [
BlackFriday2022RoutingModule,
CommonModule,
GfPremiumIndicatorModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class BlackFriday2022PageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: TheImportanceOfTrackingYourPersonalFinancesPageComponent,
path: '',
title: 'The importance of tracking your personal finances'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class TheImportanceOfTrackingYourPersonalFinancesRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-the-importance-of-tracking-your-personal-finances-page',
styleUrls: ['./the-importance-of-tracking-your-personal-finances-page.scss'],
standalone: true,
templateUrl: './the-importance-of-tracking-your-personal-finances-page.html'
})
export class TheImportanceOfTrackingYourPersonalFinancesPageComponent {}

View File

@ -1,19 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { TheImportanceOfTrackingYourPersonalFinancesRoutingModule } from './the-importance-of-tracking-your-personal-finances-page-routing.module';
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
@NgModule({
declarations: [TheImportanceOfTrackingYourPersonalFinancesPageComponent],
imports: [
CommonModule,
MatButtonModule,
RouterModule,
TheImportanceOfTrackingYourPersonalFinancesRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TheImportanceOfTrackingYourPersonalFinancesPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioAufSackgeldVorgestelltPageComponent } from './ghostfolio-auf-sackgeld-vorgestellt-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioAufSackgeldVorgestelltPageComponent,
path: '',
title: 'Ghostfolio auf Sackgeld.com vorgestellt'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioAufSackgeldVorgestelltPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-auf-sackgeld-vorgestellt-page',
styleUrls: ['./ghostfolio-auf-sackgeld-vorgestellt-page.scss'],
standalone: true,
templateUrl: './ghostfolio-auf-sackgeld-vorgestellt-page.html'
})
export class GhostfolioAufSackgeldVorgestelltPageComponent {}

View File

@ -1,17 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioAufSackgeldVorgestelltPageRoutingModule } from './ghostfolio-auf-sackgeld-vorgestellt-page-routing.module';
import { GhostfolioAufSackgeldVorgestelltPageComponent } from './ghostfolio-auf-sackgeld-vorgestellt-page.component';
@NgModule({
declarations: [GhostfolioAufSackgeldVorgestelltPageComponent],
imports: [
CommonModule,
GhostfolioAufSackgeldVorgestelltPageRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioAufSackgeldVorgestelltPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsUmbrelPageComponent,
path: '',
title: 'Ghostfolio meets Umbrel'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsUmbrelPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-meets-umbrel-page',
styleUrls: ['./ghostfolio-meets-umbrel-page.scss'],
standalone: true,
templateUrl: './ghostfolio-meets-umbrel-page.html'
})
export class GhostfolioMeetsUmbrelPageComponent {}

View File

@ -84,13 +84,13 @@
<section class="mb-4">
<p>
To participate in the ongoing development of Ghostfolio, please feel
free to reach out to us on our
free to contact us in the
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
>Slack channel</a
>Slack</a
>
or via Twitter
community or via Twitter
<a href="https://twitter.com/ghostfolio_" target="_blank"
>@ghostfolio_</a
>. We look forward to hearing from you!

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsUmbrelPageRoutingModule } from './ghostfolio-meets-umbrel-page-routing.module';
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
@NgModule({
declarations: [GhostfolioMeetsUmbrelPageComponent],
imports: [CommonModule, GhostfolioMeetsUmbrelPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsUmbrelPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ThousandStarsOnGitHubPageComponent } from './1000-stars-on-github-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: ThousandStarsOnGitHubPageComponent,
path: '',
title: 'Ghostfolio reaches 1000 Stars on GitHub'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ThousandStarsOnGitHubRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-1000-stars-on-github-page',
styleUrls: ['./1000-stars-on-github-page.scss'],
standalone: true,
templateUrl: './1000-stars-on-github-page.html'
})
export class ThousandStarsOnGitHubPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ThousandStarsOnGitHubRoutingModule } from './1000-stars-on-github-page-routing.module';
import { ThousandStarsOnGitHubPageComponent } from './1000-stars-on-github-page.component';
@NgModule({
declarations: [ThousandStarsOnGitHubPageComponent],
imports: [CommonModule, ThousandStarsOnGitHubRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ThousandStarsOnGitHubPageModule {}

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