diff --git a/CHANGELOG.md b/CHANGELOG.md index aaac689d0..f5c492add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ 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.201.0 - 01.10.2022 + +### Added + +- Added a blog post: _Hacktoberfest 2022_ + +### Changed + +- Improved the usage of the value component in the admin control panel +- Improved the language localization for Español (`es`) + +### Fixed + +- Fixed the usage of the value component on the allocations page + +## 1.200.0 - 01.10.2022 + +### Added + +- Added a mini statistics section to the landing page including pulls on _Docker Hub_ +- Added an _As seen in_ section to the landing page +- Added support for an icon in the value component + +### Changed + +- Upgraded `prisma` from version `4.1.1` to `4.4.0` + +## 1.199.1 - 27.09.2022 + +### Added + +- Set up the language localization for Español (`es`) +- Added support for sectors in mutual funds + +## 1.198.0 - 25.09.2022 + +### Added + +- Added support to exclude an account from analysis +- Set up the language localization for Nederlands (`nl`) + +## 1.197.0 - 24.09.2022 + +### Added + +- Added the value of the active filter in percentage on the allocations page +- Extended the feature overview page by multi-language support (English, German, Italian) + +### Changed + +- Combined the performance and chart calculation +- Improved the style of various selectors (density) + +## 1.196.0 - 22.09.2022 + +### Added + +- Set up the language localization for Italiano (`it`) +- Extended the landing page + ## 1.195.0 - 20.09.2022 ### Changed @@ -195,7 +255,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow -- Set up language localization for German (`de`) +- Set up the language localization for German (`de`) - Resolved the feature graphic of the blog post ### Changed diff --git a/angular.json b/angular.json index dd37696ff..1ceea18c1 100644 --- a/angular.json +++ b/angular.json @@ -136,10 +136,18 @@ "baseHref": "/en/", "localize": ["en"] }, + "development-es": { + "baseHref": "/es/", + "localize": ["es"] + }, "development-it": { "baseHref": "/it/", "localize": ["it"] }, + "development-nl": { + "baseHref": "/nl/", + "localize": ["nl"] + }, "production": { "fileReplacements": [ { @@ -184,9 +192,15 @@ "development-en": { "browserTarget": "client:build:development-en" }, + "development-es": { + "browserTarget": "client:build:development-es" + }, "development-it": { "browserTarget": "client:build:development-it" }, + "development-nl": { + "browserTarget": "client:build:development-nl" + }, "production": { "browserTarget": "client:build:production" } @@ -198,7 +212,12 @@ "browserTarget": "client:build", "includeContext": true, "outputPath": "src/locales", - "targetFiles": ["messages.de.xlf", "messages.it.xlf"] + "targetFiles": [ + "messages.de.xlf", + "messages.es.xlf", + "messages.it.xlf", + "messages.nl.xlf" + ] } }, "lint": { @@ -222,9 +241,17 @@ "baseHref": "/de/", "translation": "apps/client/src/locales/messages.de.xlf" }, + "es": { + "baseHref": "/es/", + "translation": "apps/client/src/locales/messages.es.xlf" + }, "it": { "baseHref": "/it/", "translation": "apps/client/src/locales/messages.it.xlf" + }, + "nl": { + "baseHref": "/nl/", + "translation": "apps/client/src/locales/messages.nl.xlf" } }, "sourceLocale": "en" diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 524e36f5a..3ef671cc4 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -96,7 +96,9 @@ export class AccountController { let accountsWithAggregations = await this.portfolioService.getAccountsWithAggregations( - impersonationUserId || this.request.user.id + impersonationUserId || this.request.user.id, + undefined, + true ); if ( @@ -139,7 +141,8 @@ export class AccountController { let accountsWithAggregations = await this.portfolioService.getAccountsWithAggregations( impersonationUserId || this.request.user.id, - [{ id, type: 'ACCOUNT' }] + [{ id, type: 'ACCOUNT' }], + true ); if ( diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index b9b65716a..7c10fc31f 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -107,15 +107,23 @@ export class AccountService { public async getCashDetails({ currency, filters = [], - userId + userId, + withExcludedAccounts = false }: { currency: string; filters?: Filter[]; userId: string; + withExcludedAccounts?: boolean; }): Promise { let totalCashBalanceInBaseCurrency = new Big(0); - const where: Prisma.AccountWhereInput = { userId }; + const where: Prisma.AccountWhereInput = { + userId + }; + + if (withExcludedAccounts === false) { + where.isExcluded = false; + } const { ACCOUNT: filtersByAccount, diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index f53a20e76..3ea13e20a 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -1,5 +1,11 @@ import { AccountType } from '@prisma/client'; -import { IsNumber, IsString, ValidateIf } from 'class-validator'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateIf +} from 'class-validator'; export class CreateAccountDto { @IsString() @@ -11,6 +17,10 @@ export class CreateAccountDto { @IsString() currency: string; + @IsBoolean() + @IsOptional() + isExcluded?: boolean; + @IsString() name: string; diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts index 343f46a7a..0b5737607 100644 --- a/apps/api/src/app/account/update-account.dto.ts +++ b/apps/api/src/app/account/update-account.dto.ts @@ -1,5 +1,11 @@ import { AccountType } from '@prisma/client'; -import { IsNumber, IsString, ValidateIf } from 'class-validator'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateIf +} from 'class-validator'; export class UpdateAccountDto { @IsString() @@ -14,6 +20,10 @@ export class UpdateAccountDto { @IsString() id: string; + @IsBoolean() + @IsOptional() + isExcluded?: boolean; + @IsString() name: string; diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 9c60757f5..68968b09f 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -164,7 +164,7 @@ export class BenchmarkService { ); const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0; - return { + const response = { marketData: [ ...marketDataItems .filter((marketDataItem, index) => { @@ -181,17 +181,22 @@ export class BenchmarkService { marketDataItem.marketPrice ) * 100 }; - }), - { - date: format(new Date(), DATE_FORMAT), - value: - this.calculateChangeInPercentage( - marketPriceAtStartDate, - currentSymbolItem.marketPrice - ) * 100 - } + }) ] }; + + if (currentSymbolItem?.marketPrice) { + response.marketData.push({ + date: format(new Date(), DATE_FORMAT), + value: + this.calculateChangeInPercentage( + marketPriceAtStartDate, + currentSymbolItem.marketPrice + ) * 100 + }); + } + + return response; } private getMarketCondition(aPerformanceInPercent: number) { diff --git a/apps/api/src/app/frontend.middleware.ts b/apps/api/src/app/frontend.middleware.ts index b650555fd..a1ab6452c 100644 --- a/apps/api/src/app/frontend.middleware.ts +++ b/apps/api/src/app/frontend.middleware.ts @@ -11,6 +11,9 @@ import { NextFunction, Request, Response } from 'express'; export class FrontendMiddleware implements NestMiddleware { public indexHtmlDe = ''; public indexHtmlEn = ''; + public indexHtmlEs = ''; + public indexHtmlIt = ''; + public indexHtmlNl = ''; public isProduction: boolean; public constructor( @@ -32,6 +35,18 @@ export class FrontendMiddleware implements NestMiddleware { this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), 'utf8' ); + this.indexHtmlEs = fs.readFileSync( + this.getPathOfIndexHtmlFile('es'), + 'utf8' + ); + this.indexHtmlIt = fs.readFileSync( + this.getPathOfIndexHtmlFile('it'), + 'utf8' + ); + this.indexHtmlNl = fs.readFileSync( + this.getPathOfIndexHtmlFile('nl'), + 'utf8' + ); } catch {} } @@ -43,6 +58,11 @@ export class FrontendMiddleware implements NestMiddleware { req.path === '/en/blog/2022/08/500-stars-on-github/' ) { featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; + } else if ( + req.path === '/en/blog/2022/10/hacktoberfest-2022' || + req.path === '/en/blog/2022/10/hacktoberfest-2022/' + ) { + featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; } if ( @@ -61,6 +81,33 @@ export class FrontendMiddleware implements NestMiddleware { rootUrl: this.configurationService.get('ROOT_URL') }) ); + } else if (req.path === '/es' || req.path.startsWith('/es/')) { + res.send( + this.interpolate(this.indexHtmlEs, { + featureGraphicPath, + languageCode: 'es', + path: req.path, + rootUrl: this.configurationService.get('ROOT_URL') + }) + ); + } else if (req.path === '/it' || req.path.startsWith('/it/')) { + res.send( + this.interpolate(this.indexHtmlIt, { + featureGraphicPath, + languageCode: 'it', + path: req.path, + rootUrl: this.configurationService.get('ROOT_URL') + }) + ); + } else if (req.path === '/nl' || req.path.startsWith('/nl/')) { + res.send( + this.interpolate(this.indexHtmlNl, { + featureGraphicPath, + languageCode: 'nl', + path: req.path, + rootUrl: this.configurationService.get('ROOT_URL') + }) + ); } else { res.send( this.interpolate(this.indexHtmlEn, { diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index e4f906b9a..8ed589cb5 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -145,6 +145,27 @@ export class InfoService { }); } + private async countDockerHubPulls(): Promise { + try { + const get = bent( + `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, + 'GET', + 'json', + 200, + { + 'User-Agent': 'request' + } + ); + + const { pull_count } = await get(); + return pull_count; + } catch (error) { + Logger.error(error, 'InfoService'); + + return undefined; + } + } + private async countGitHubContributors(): Promise { try { const get = bent( @@ -245,6 +266,8 @@ export class InfoService { const activeUsers1d = await this.countActiveUsers(1); const activeUsers30d = await this.countActiveUsers(30); const newUsers30d = await this.countNewUsers(30); + + const dockerHubPulls = await this.countDockerHubPulls(); const gitHubContributors = await this.countGitHubContributors(); const gitHubStargazers = await this.countGitHubStargazers(); const slackCommunityUsers = await this.countSlackCommunityUsers(); @@ -252,6 +275,7 @@ export class InfoService { statistics = { activeUsers1d, activeUsers30d, + dockerHubPulls, gitHubContributors, gitHubStargazers, newUsers30d, diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index e3cafefca..a0c606b8c 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -109,7 +109,8 @@ export class OrderController { filters, userCurrency, includeDrafts: true, - userId: impersonationUserId || this.request.user.id + userId: impersonationUserId || this.request.user.id, + withExcludedAccounts: true }); if ( diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index bf549200e..b95c96975 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -189,13 +189,15 @@ export class OrderService { includeDrafts = false, types, userCurrency, - userId + userId, + withExcludedAccounts = false }: { filters?: Filter[]; includeDrafts?: boolean; types?: TypeOfOrder[]; userCurrency: string; userId: string; + withExcludedAccounts?: boolean; }): Promise { const where: Prisma.OrderWhereInput = { userId }; @@ -284,24 +286,28 @@ export class OrderService { }, orderBy: { date: 'asc' } }) - ).map((order) => { - const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); - - return { - ...order, - value, - feeInBaseCurrency: this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - userCurrency - ), - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + ) + .filter((order) => { + return withExcludedAccounts || order.Account?.isExcluded === false; + }) + .map((order) => { + const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); + + return { + ...order, value, - order.SymbolProfile.currency, - userCurrency - ) - }; - }); + feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + order.fee, + order.SymbolProfile.currency, + userCurrency + ), + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + order.SymbolProfile.currency, + userCurrency + ) + }; + }); } public async updateOrder({ diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 44046a60f..46fe092bb 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -272,23 +272,20 @@ export class PortfolioCalculator { } } - const isInPercentage = true; - return Object.keys(totalNetPerformanceValues).map((date) => { - return isInPercentage - ? { - date, - value: totalInvestmentValues[date].eq(0) - ? 0 - : totalNetPerformanceValues[date] - .div(totalInvestmentValues[date]) - .mul(100) - .toNumber() - } - : { - date, - value: totalNetPerformanceValues[date].toNumber() - }; + const netPerformanceInPercentage = totalInvestmentValues[date].eq(0) + ? 0 + : totalNetPerformanceValues[date] + .div(totalInvestmentValues[date]) + .mul(100) + .toNumber(); + + return { + date, + netPerformanceInPercentage, + netPerformance: totalNetPerformanceValues[date].toNumber(), + value: netPerformanceInPercentage + }; }); } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 86bc8c2fa..4a017388f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -110,26 +110,6 @@ export class PortfolioController { }; } - @Get('chart') - @UseGuards(AuthGuard('jwt')) - @Version('2') - public async getChartV2( - @Headers('impersonation-id') impersonationId: string, - @Query('range') range - ): Promise { - const historicalDataContainer = await this.portfolioService.getChartV2( - impersonationId, - range - ); - - return { - chart: historicalDataContainer.items, - hasError: false, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - @Get('details') @UseGuards(AuthGuard('jwt')) @UseInterceptors(RedactValuesInResponseInterceptor) @@ -168,12 +148,15 @@ export class PortfolioController { }) ]; + let portfolioSummary: PortfolioSummary; + const { accounts, filteredValueInBaseCurrency, filteredValueInPercentage, hasErrors, holdings, + summary, totalValueInBaseCurrency } = await this.portfolioService.getDetails( impersonationId, @@ -186,6 +169,8 @@ export class PortfolioController { hasError = true; } + portfolioSummary = summary; + if ( impersonationId || this.userService.isRestrictedView(this.request.user) @@ -219,6 +204,22 @@ export class PortfolioController { accounts[name].current = current / totalValue; accounts[name].original = original / totalInvestment; } + + portfolioSummary = nullifyValuesInObject(summary, [ + 'cash', + 'committedFunds', + 'currentGrossPerformance', + 'currentNetPerformance', + 'currentValue', + 'dividend', + 'emergencyFund', + 'excludedAccountsAndActivities', + 'fees', + 'items', + 'netWorth', + 'totalBuy', + 'totalSell' + ]); } let hasDetails = true; @@ -244,7 +245,8 @@ export class PortfolioController { filteredValueInPercentage, hasError, holdings, - totalValueInBaseCurrency + totalValueInBaseCurrency, + summary: hasDetails ? portfolioSummary : undefined }; } @@ -319,6 +321,35 @@ export class PortfolioController { return performanceInformation; } + @Get('performance') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + @Version('2') + public async getPerformanceV2( + @Headers('impersonation-id') impersonationId: string, + @Query('range') dateRange + ): Promise { + const performanceInformation = await this.portfolioService.getPerformanceV2( + { + dateRange, + impersonationId + } + ); + + if ( + impersonationId || + this.request.user.Settings.settings.viewMode === 'ZEN' || + this.userService.isRestrictedView(this.request.user) + ) { + performanceInformation.performance = nullifyValuesInObject( + performanceInformation.performance, + ['currentGrossPerformance', 'currentNetPerformance', 'currentValue'] + ); + } + + return performanceInformation; + } + @Get('positions') @UseGuards(AuthGuard('jwt')) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -411,46 +442,6 @@ export class PortfolioController { return portfolioPublicDetails; } - @Get('summary') - @UseGuards(AuthGuard('jwt')) - public async getSummary( - @Headers('impersonation-id') impersonationId - ): Promise { - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - - let summary = await this.portfolioService.getSummary(impersonationId); - - if ( - impersonationId || - this.userService.isRestrictedView(this.request.user) - ) { - summary = nullifyValuesInObject(summary, [ - 'cash', - 'committedFunds', - 'currentGrossPerformance', - 'currentNetPerformance', - 'currentValue', - 'dividend', - 'emergencyFund', - 'fees', - 'items', - 'netWorth', - 'totalBuy', - 'totalSell' - ]); - } - - return summary; - } - @Get('position/:dataSource/:symbol') @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 36b7bd48e..a49bda912 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -50,8 +50,11 @@ import type { import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { + Account, AssetClass, DataSource, + Order, + Platform, Prisma, Tag, Type as TypeOfOrder @@ -106,7 +109,8 @@ export class PortfolioService { public async getAccounts( aUserId: string, - aFilters?: Filter[] + aFilters?: Filter[], + withExcludedAccounts = false ): Promise { const where: Prisma.AccountWhereInput = { userId: aUserId }; @@ -120,7 +124,13 @@ export class PortfolioService { include: { Order: true, Platform: true }, orderBy: { name: 'asc' } }), - this.getDetails(aUserId, aUserId, undefined, aFilters) + this.getDetails( + aUserId, + aUserId, + undefined, + aFilters, + withExcludedAccounts + ) ]); const userCurrency = this.request.user.Settings.settings.baseCurrency; @@ -160,9 +170,14 @@ export class PortfolioService { public async getAccountsWithAggregations( aUserId: string, - aFilters?: Filter[] + aFilters?: Filter[], + withExcludedAccounts = false ): Promise { - const accounts = await this.getAccounts(aUserId, aFilters); + const accounts = await this.getAccounts( + aUserId, + aFilters, + withExcludedAccounts + ); let totalBalanceInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0); let transactionCount = 0; @@ -355,11 +370,14 @@ export class PortfolioService { }; } - public async getChartV2( - aImpersonationId: string, - aDateRange: DateRange = 'max' - ): Promise { - const userId = await this.getUserId(aImpersonationId, this.request.user.id); + public async getChartV2({ + dateRange = 'max', + impersonationId + }: { + dateRange?: DateRange; + impersonationId: string; + }): Promise { + const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ @@ -383,7 +401,7 @@ export class PortfolioService { const endDate = new Date(); const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(aDateRange, portfolioStart); + const startDate = this.getStartDate(dateRange, portfolioStart); const daysInMarket = differenceInDays(new Date(), startDate); const step = Math.round( @@ -407,7 +425,8 @@ export class PortfolioService { aImpersonationId: string, aUserId: string, aDateRange: DateRange = 'max', - aFilters?: Filter[] + aFilters?: Filter[], + withExcludedAccounts = false ): Promise { const userId = await this.getUserId(aImpersonationId, aUserId); const user = await this.userService.user({ id: userId }); @@ -423,6 +442,7 @@ export class PortfolioService { const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ userId, + withExcludedAccounts, filters: aFilters }); @@ -577,6 +597,7 @@ export class PortfolioService { portfolioItemsNow, userCurrency, userId, + withExcludedAccounts, filters: aFilters }); @@ -585,6 +606,7 @@ export class PortfolioService { return { accounts, holdings, + summary, filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), filteredValueInPercentage: summary.netWorth ? filteredValueInBaseCurrency.div(summary.netWorth).toNumber() @@ -603,7 +625,11 @@ export class PortfolioService { const userId = await this.getUserId(aImpersonationId, this.request.user.id); const orders = ( - await this.orderService.getOrders({ userCurrency, userId }) + await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccounts: true + }) ).filter(({ SymbolProfile }) => { return ( SymbolProfile.dataSource === aDataSource && @@ -987,6 +1013,105 @@ export class PortfolioService { }; } + public async getPerformanceV2({ + dateRange = 'max', + impersonationId + }: { + dateRange?: DateRange; + impersonationId: string; + }): Promise { + const userId = await this.getUserId(impersonationId, this.request.user.id); + + const { portfolioOrders, transactionPoints } = + await this.getTransactionPoints({ + userId + }); + + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.settings.baseCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + if (transactionPoints?.length <= 0) { + return { + chart: [], + hasErrors: false, + performance: { + currentGrossPerformance: 0, + currentGrossPerformancePercent: 0, + currentNetPerformance: 0, + currentNetPerformancePercent: 0, + currentValue: 0 + } + }; + } + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(dateRange, portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); + + const hasErrors = currentPositions.hasErrors; + const currentValue = currentPositions.currentValue.toNumber(); + const currentGrossPerformance = currentPositions.grossPerformance; + const currentGrossPerformancePercent = + currentPositions.grossPerformancePercentage; + let currentNetPerformance = currentPositions.netPerformance; + let currentNetPerformancePercent = + currentPositions.netPerformancePercentage; + + // if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) { + // // If algebraic sign is different, harmonize it + // currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1); + // } + + // if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) { + // // If algebraic sign is different, harmonize it + // currentNetPerformancePercent = currentNetPerformancePercent.mul(-1); + // } + + const historicalDataContainer = await this.getChartV2({ + dateRange, + impersonationId + }); + + const itemOfToday = historicalDataContainer.items.find((item) => { + return item.date === format(new Date(), DATE_FORMAT); + }); + + if (itemOfToday) { + currentNetPerformance = new Big(itemOfToday.netPerformance); + currentNetPerformancePercent = new Big( + itemOfToday.netPerformanceInPercentage + ).div(100); + } + + return { + chart: historicalDataContainer.items.map( + ({ date, netPerformanceInPercentage }) => { + return { + date, + value: netPerformanceInPercentage + }; + } + ), + errors: currentPositions.errors, + hasErrors: currentPositions.hasErrors || hasErrors, + performance: { + currentValue, + currentGrossPerformance: currentGrossPerformance.toNumber(), + currentGrossPerformancePercent: + currentGrossPerformancePercent.toNumber(), + currentNetPerformance: currentNetPerformance.toNumber(), + currentNetPerformancePercent: currentNetPerformancePercent.toNumber() + } + }; + } + public async getReport(impersonationId: string): Promise { const currency = this.request.user.Settings.settings.baseCurrency; const userId = await this.getUserId(impersonationId, this.request.user.id); @@ -1079,74 +1204,6 @@ export class PortfolioService { }; } - public async getSummary(aImpersonationId: string): Promise { - const userCurrency = this.request.user.Settings.settings.baseCurrency; - const userId = await this.getUserId(aImpersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - - const performanceInformation = await this.getPerformance(aImpersonationId); - - const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ - userId, - currency: userCurrency - }); - const orders = await this.orderService.getOrders({ - userCurrency, - userId - }); - const dividend = this.getDividend(orders).toNumber(); - const emergencyFund = new Big( - (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 - ); - const fees = this.getFees(orders).toNumber(); - const firstOrderDate = orders[0]?.date; - const items = this.getItems(orders).toNumber(); - - const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); - const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); - - const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber(); - const committedFunds = new Big(totalBuy).minus(totalSell); - - const netWorth = new Big(balanceInBaseCurrency) - .plus(performanceInformation.performance.currentValue) - .plus(items) - .toNumber(); - - const daysInMarket = differenceInDays(new Date(), firstOrderDate); - - const annualizedPerformancePercent = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - orders: [] - }) - .getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercent: new Big( - performanceInformation.performance.currentNetPerformancePercent - ) - }) - ?.toNumber(); - - return { - ...performanceInformation.performance, - annualizedPerformancePercent, - cash, - dividend, - fees, - firstOrderDate, - items, - netWorth, - totalBuy, - totalSell, - committedFunds: committedFunds.toNumber(), - emergencyFund: emergencyFund.toNumber(), - ordersCount: orders.filter((order) => { - return order.type === 'BUY' || order.type === 'SELL'; - }).length - }; - } - private async getCashPositions({ cashDetails, emergencyFund, @@ -1322,14 +1379,117 @@ export class PortfolioService { return portfolioStart; } + private async getSummary( + aImpersonationId: string + ): Promise { + const userCurrency = this.request.user.Settings.settings.baseCurrency; + const userId = await this.getUserId(aImpersonationId, this.request.user.id); + const user = await this.userService.user({ id: userId }); + + const performanceInformation = await this.getPerformance(aImpersonationId); + + const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ + userId, + currency: userCurrency + }); + const orders = await this.orderService.getOrders({ + userCurrency, + userId + }); + + const excludedActivities = ( + await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccounts: true + }) + ).filter(({ Account: account }) => { + return account?.isExcluded ?? false; + }); + + const dividend = this.getDividend(orders).toNumber(); + const emergencyFund = new Big( + (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 + ); + const fees = this.getFees(orders).toNumber(); + const firstOrderDate = orders[0]?.date; + const items = this.getItems(orders).toNumber(); + + const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); + const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); + + const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber(); + const committedFunds = new Big(totalBuy).minus(totalSell); + const totalOfExcludedActivities = new Big( + this.getTotalByType(excludedActivities, userCurrency, 'BUY') + ).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); + + const cashDetailsWithExcludedAccounts = + await this.accountService.getCashDetails({ + userId, + currency: userCurrency, + withExcludedAccounts: true + }); + + const excludedBalanceInBaseCurrency = new Big( + cashDetailsWithExcludedAccounts.balanceInBaseCurrency + ).minus(balanceInBaseCurrency); + + const excludedAccountsAndActivities = excludedBalanceInBaseCurrency + .plus(totalOfExcludedActivities) + .toNumber(); + + const netWorth = new Big(balanceInBaseCurrency) + .plus(performanceInformation.performance.currentValue) + .plus(items) + .plus(excludedAccountsAndActivities) + .toNumber(); + + const daysInMarket = differenceInDays(new Date(), firstOrderDate); + + const annualizedPerformancePercent = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: [] + }) + .getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercent: new Big( + performanceInformation.performance.currentNetPerformancePercent + ) + }) + ?.toNumber(); + + return { + ...performanceInformation.performance, + annualizedPerformancePercent, + cash, + dividend, + excludedAccountsAndActivities, + fees, + firstOrderDate, + items, + netWorth, + totalBuy, + totalSell, + committedFunds: committedFunds.toNumber(), + emergencyFund: emergencyFund.toNumber(), + ordersCount: orders.filter((order) => { + return order.type === 'BUY' || order.type === 'SELL'; + }).length + }; + } + private async getTransactionPoints({ filters, includeDrafts = false, - userId + userId, + withExcludedAccounts }: { filters?: Filter[]; includeDrafts?: boolean; userId: string; + withExcludedAccounts?: boolean; }): Promise<{ transactionPoints: TransactionPoint[]; orders: OrderWithAccount[]; @@ -1343,6 +1503,7 @@ export class PortfolioService { includeDrafts, userCurrency, userId, + withExcludedAccounts, types: ['BUY', 'SELL'] }); @@ -1394,17 +1555,22 @@ export class PortfolioService { orders, portfolioItemsNow, userCurrency, - userId + userId, + withExcludedAccounts }: { filters?: Filter[]; orders: OrderWithAccount[]; portfolioItemsNow: { [p: string]: TimelinePosition }; userCurrency: string; userId: string; + withExcludedAccounts?: boolean; }) { const accounts: PortfolioDetails['accounts'] = {}; - let currentAccounts = []; + let currentAccounts: (Account & { + Order?: Order[]; + Platform?: Platform; + })[] = []; if (filters.length === 0) { currentAccounts = await this.accountService.getAccounts(userId); @@ -1424,6 +1590,10 @@ export class PortfolioService { }); } + currentAccounts = currentAccounts.filter((account) => { + return withExcludedAccounts || account.isExcluded === false; + }); + for (const account of currentAccounts) { const ordersByAccount = orders.filter(({ accountId }) => { return accountId === account.id; diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 2a7e93ed6..bc408ca4d 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -6,6 +6,7 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -90,7 +91,7 @@ export class YahooFinanceService implements DataProviderInterface { try { const symbol = this.convertToYahooFinanceSymbol(aSymbol); const assetProfile = await yahooFinance.quoteSummary(symbol, { - modules: ['price', 'summaryProfile'] + modules: ['price', 'summaryProfile', 'topHoldings'] }); const { assetClass, assetSubClass } = this.parseAssetClass( @@ -109,7 +110,16 @@ export class YahooFinanceService implements DataProviderInterface { }); response.symbol = aSymbol; - if ( + if (assetSubClass === AssetSubClass.MUTUALFUND) { + response.sectors = []; + + for (const sectorWeighting of assetProfile.topHoldings + ?.sectorWeightings ?? []) { + for (const [sector, weight] of Object.entries(sectorWeighting)) { + response.sectors.push({ weight, name: this.parseSector(sector) }); + } + } + } else if ( assetSubClass === AssetSubClass.STOCK && assetProfile.summaryProfile?.country ) { @@ -437,4 +447,46 @@ export class YahooFinanceService implements DataProviderInterface { return { assetClass, assetSubClass }; } + + private parseSector(aString: string): string { + let sector = UNKNOWN_KEY; + + switch (aString) { + case 'basic_materials': + sector = 'Basic Materials'; + break; + case 'communication_services': + sector = 'Communication Services'; + break; + case 'consumer_cyclical': + sector = 'Consumer Cyclical'; + break; + case 'consumer_defensive': + sector = 'Consumer Staples'; + break; + case 'energy': + sector = 'Energy'; + break; + case 'financial_services': + sector = 'Financial Services'; + break; + case 'healthcare': + sector = 'Healthcare'; + break; + case 'industrials': + sector = 'Industrials'; + break; + case 'realestate': + sector = 'Real Estate'; + break; + case 'technology': + sector = 'Technology'; + break; + case 'utilities': + sector = 'Utilities'; + break; + } + + return sector; + } } diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index da506e673..856230eb6 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -95,6 +95,13 @@ const routes: Routes = [ './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: 'demo', loadChildren: () => diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index ca2f229e7..a0c11bce8 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -61,7 +61,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => { this.accountType = accountType; this.name = name; - this.platformName = Platform?.name; + this.platformName = Platform?.name ?? '-'; this.valueInBaseCurrency = valueInBaseCurrency; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index edf111ffc..53a9c76ea 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -21,10 +21,12 @@
- Account Type + Account Type
- Platform + Platform
diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index 74ece2ade..49de5decc 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -2,7 +2,10 @@
- +
- - + +
- + Compare with...
-
+
{ - this.historicalDataItems = chartData.chart.map((chartDataItem) => { - return { - date: chartDataItem.date, - value: chartDataItem.value - }; - }); - this.isAllTimeHigh = chartData.isAllTimeHigh; - this.isAllTimeLow = chartData.isAllTimeLow; - - this.changeDetectorRef.markForCheck(); - }); - - this.dataService - .fetchPortfolioPerformance({ range: this.user?.settings?.dateRange }) - .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((response) => { this.errors = response.errors; this.hasError = response.hasErrors; this.performance = response.performance; this.isLoadingPerformance = false; + if (this.user?.settings?.isExperimentalFeatures) { + this.historicalDataItems = response.chart.map(({ date, value }) => { + return { + date, + value + }; + }); + } else { + this.dataService + .fetchChart({ + range: this.user?.settings?.dateRange, + version: 1 + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((chartData) => { + this.historicalDataItems = chartData.chart.map( + ({ date, value }) => { + return { + date, + value + }; + } + ); + this.isAllTimeHigh = chartData.isAllTimeHigh; + this.isAllTimeLow = chartData.isAllTimeLow; + + this.changeDetectorRef.markForCheck(); + }); + } + this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/home-summary/home-summary.component.ts b/apps/client/src/app/components/home-summary/home-summary.component.ts index 106aba6c9..f10f09fe3 100644 --- a/apps/client/src/app/components/home-summary/home-summary.component.ts +++ b/apps/client/src/app/components/home-summary/home-summary.component.ts @@ -1,8 +1,18 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { + MatSnackBar, + MatSnackBarRef, + TextOnlySnackBar +} from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; 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'; -import { PortfolioSummary, User } from '@ghostfolio/common/interfaces'; +import { + InfoItem, + PortfolioSummary, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -14,8 +24,11 @@ import { takeUntil } from 'rxjs/operators'; }) export class HomeSummaryComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; + public hasPermissionForSubscription: boolean; public hasPermissionToUpdateUserSettings: boolean; + public info: InfoItem; public isLoading = true; + public snackBarRef: MatSnackBarRef; public summary: PortfolioSummary; public user: User; @@ -25,8 +38,17 @@ export class HomeSummaryComponent implements OnDestroy, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private impersonationStorageService: ImpersonationStorageService, + private router: Router, + private snackBar: MatSnackBar, private userService: UserService ) { + this.info = this.dataService.fetchInfo(); + + this.hasPermissionForSubscription = hasPermission( + this.info?.globalPermissions, + permissions.enableSubscription + ); + this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { @@ -50,8 +72,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit { .subscribe((aId) => { this.hasImpersonationId = !!aId; }); - - this.update(); } public onChangeEmergencyFund(emergencyFund: number) { @@ -81,12 +101,30 @@ export class HomeSummaryComponent implements OnDestroy, OnInit { this.isLoading = true; this.dataService - .fetchPortfolioSummary() + .fetchPortfolioDetails({}) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((response) => { - this.summary = response; + .subscribe(({ summary }) => { + this.summary = summary; this.isLoading = false; + if (!this.summary) { + this.snackBarRef = this.snackBar.open( + $localize`This feature requires a subscription.`, + this.hasPermissionForSubscription + ? $localize`Upgrade Plan` + : undefined, + { duration: 6000 } + ); + + this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef = undefined; + }); + + this.snackBarRef.onAction().subscribe(() => { + this.router.navigate(['/pricing']); + }); + } + this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 577d41741..78e30675c 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -172,6 +172,17 @@ >
+
+
Excluded from Analysis
+
+ +
+

diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 8a04e2b5e..f741058a9 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -54,7 +54,7 @@ export class AccountPageComponent implements OnDestroy, OnInit { public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; public language = document.documentElement.lang; - public locales = ['de', 'de-CH', 'en-GB', 'en-US']; + public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'es', 'it', 'nl']; public price: number; public priceId: string; public snackBarRef: MatSnackBarRef; diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 306a9f70a..e3c446f57 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -94,7 +94,10 @@ Base Currency
- + Beta
- + Deutsch English + Español + Italiano + Nederlands
@@ -138,7 +147,10 @@
- +
- + { - return account.id === params['accountId']; + const account = this.accounts.find(({ id }) => { + return id === params['accountId']; }); this.openUpdateAccountDialog(account); @@ -155,6 +155,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit { balance, currency, id, + isExcluded, name, platformId }: AccountModel): void { @@ -165,6 +166,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit { balance, currency, id, + isExcluded, name, platformId } @@ -231,6 +233,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit { accountType: AccountType.SECURITIES, balance: 0, currency: this.user?.settings?.baseCurrency, + isExcluded: false, name: null, platformId: null } diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index 971487356..ba2a1cee2 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -50,6 +50,14 @@
+
+ Exclude from Analysis +
Account ID diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts index 3a3f2f517..528835f9a 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +16,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c CommonModule, FormsModule, MatButtonModule, + MatCheckboxModule, MatDialogModule, MatFormFieldModule, MatInputModule, diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page-routing.module.ts b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page-routing.module.ts new file mode 100644 index 000000000..7ee6a0cff --- /dev/null +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page-routing.module.ts @@ -0,0 +1,20 @@ +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 {} diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts new file mode 100644 index 000000000..7417a09bf --- /dev/null +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + host: { class: 'page' }, + selector: 'gf-hacktoberfest-2022-page', + styleUrls: ['./hacktoberfest-2022-page.scss'], + templateUrl: './hacktoberfest-2022-page.html' +}) +export class Hacktoberfest2022PageComponent {} diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html new file mode 100644 index 000000000..f2d9e90b4 --- /dev/null +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html @@ -0,0 +1,178 @@ +
+
+
+
+
+

Hacktoberfest 2022

+
2022-10-01
+ Hacktoberfest 2022 with Ghostfolio Teaser +
+
+

+ We are very excited to join + Hacktoberfest for the first + time with Ghostfolio and meet new + and ambitious open-source contributors. Hacktoberfest is a + month-long celebration of open-source projects, their maintainers, + and the entire community of contributors. Each October, open source + maintainers from all over the world give extra attention to new + contributors while guiding them through their first pull requests on + GitHub. +

+
+
+

About Ghostfolio

+

+ Ghostfolio is a modern web application to manage your personal + finance. The software presents the current assets in real time and + supports the decision making of future investments. Whether + rebalancing the asset classes (stocks, ETFs, cryptocurrencies, etc.) + of your portfolio or financing an apartment, Ghostfolio offers + solid, data-driven decision support. +

+

+ Ghostfolio is written in + TypeScript and + organized as an Nx workspace. The + backend is based on NestJS using + PostgreSQL as a database + together with Prisma and + Redis for caching. The frontend is + built with Angular. +

+
+
+

How to contribute?

+

+ Every contribution matters. This can be implementing new features, + fixing bugs, refactoring the code, improving the documentation, + adding more unit tests, or translating into another language. +

+

+ Are you not yet familiar with our code base? That is not a problem. + We have applied the label hacktoberfest to a few + issues + and + ideas + that are well suited for newcomers. +

+

+ The official Hacktoberfest website provides some valuable + resources for beginners + to start contributing in open source. +

+
+
+

Get support

+

+ If you have further questions or ideas, please join our growing + Slack community or get in + touch on Twitter + @ghostfolio_ or by + email via hi@ghostfol.io. +

+

+ We look forward to hearing from you.
+ Thomas from Ghostfolio +

+
+
+
    +
  • + Angular +
  • +
  • + Community +
  • +
  • + Cryptocurrency +
  • +
  • + ETF +
  • +
  • + Finance +
  • +
  • + Fintech +
  • +
  • + Ghostfolio +
  • +
  • + GitHub +
  • +
  • + Hacktoberfest +
  • +
  • + Investment +
  • +
  • + NestJS +
  • +
  • + Nx +
  • +
  • + October +
  • +
  • + Open Source +
  • +
  • + OSS +
  • +
  • + Personal Finance +
  • +
  • + Portfolio +
  • +
  • + Portfolio Tracker +
  • +
  • + Prisma +
  • +
  • + Software +
  • +
  • + Stock +
  • +
  • + TypeScript +
  • +
  • + Wealth +
  • +
  • + Wealth Management +
  • +
  • + Web3 +
  • +
  • + Web 3.0 +
  • +
+
+
+
+
+
diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module.ts b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module.ts new file mode 100644 index 000000000..fba8a359d --- /dev/null +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module.ts @@ -0,0 +1,13 @@ +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 {} diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.scss b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/pages/blog/blog-page.html b/apps/client/src/app/pages/blog/blog-page.html index 2cdd2a604..f2d1de322 100644 --- a/apps/client/src/app/pages/blog/blog-page.html +++ b/apps/client/src/app/pages/blog/blog-page.html @@ -2,6 +2,30 @@

Blog

+ + + + +
diff --git a/apps/client/src/app/pages/features/features-page.html b/apps/client/src/app/pages/features/features-page.html index 691b63127..8dc184326 100644 --- a/apps/client/src/app/pages/features/features-page.html +++ b/apps/client/src/app/pages/features/features-page.html @@ -192,6 +192,17 @@
+
+ +
+

Multi-Language

+

+ Use Ghostfolio in multiple languages: English, Dutch, German, + Italian and Spanish are currently supported. +

+
+
+
diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index 26f27d991..7fef8b092 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -1,4 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { format } from 'date-fns'; import { Subject } from 'rxjs'; @@ -11,6 +14,8 @@ import { Subject } from 'rxjs'; export class LandingPageComponent implements OnDestroy, OnInit { public currentYear = format(new Date(), 'yyyy'); public demoAuthToken: string; + public hasPermissionForStatistics: boolean; + public statistics: Statistics; public testimonials = [ { author: 'Philipp', @@ -36,7 +41,16 @@ export class LandingPageComponent implements OnDestroy, OnInit { private unsubscribeSubject = new Subject(); - public constructor() {} + public constructor(private dataService: DataService) { + const { globalPermissions, statistics } = this.dataService.fetchInfo(); + + this.hasPermissionForStatistics = hasPermission( + globalPermissions, + permissions.enableStatistics + ); + + this.statistics = statistics; + } public ngOnInit() {} diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 7527faa13..9ddd83106 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -42,6 +42,103 @@
+ + +
+
As seen in
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

@@ -55,6 +152,28 @@

+
+
+ + 360° View + Get the full picture of your personal finances across multiple + platforms. + +
+
+ + Web3 Ready + Use Ghostfolio anonymously and own your financial data. + +
+
+ + Open Source + Benefit from continuous improvements through a strong community. + +
+
+

Why Ghostfolio?

@@ -133,24 +252,48 @@
-
-
+
+

How does Ghostfolio work?

Get started in only 3 steps

-
    -
  1. - Sign up anonymously
    (no e-mail address nor credit card required) -
  2. -
  3. Add any of your historical transactions
  4. -
  5. Get valuable insights of your portfolio composition
  6. -
+
+
+ +
+
Sign up anonymously*
+
+ * no e-mail address nor credit card required +
+
+
1
+
+
+
+ +
+
+ Add any of your historical transactions +
+
+
2
+
+
+
+ +
+
+ Get valuable insights of your portfolio composition +
+
+
3
+
-
+

Are you ready?

Join now or check out the example account @@ -194,7 +337,7 @@