diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0ad2fca..ba8c5f80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.96.0 - 2024-07-13 + +### Changed + +- Improved the chart of the holdings tab on the home page (experimental) +- Separated the icon purposes in the `site.webmanifest` + +### Fixed + +- Fixed an issue in the portfolio summary with the currency conversion of fees +- Fixed an issue in the the search for a holding +- Removed the show condition of the experimental features setting in the user settings + +## 2.95.0 - 2024-07-12 + +### Added + +- Added a chart to the holdings tab of the home page (experimental) + ## 2.94.0 - 2024-07-09 ### Changed diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 1564fa5b3..37876dde0 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -174,8 +174,8 @@ export class AccountService { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, TAG: filtersByTag - } = groupBy(filters, (filter) => { - return filter.type; + } = groupBy(filters, ({ type }) => { + return type; }); if (filtersByAccount?.length > 0) { diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 985aabfbb..5d8e1f51b 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -313,10 +313,14 @@ export class OrderService { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, TAG: filtersByTag - } = groupBy(filters, (filter) => { - return filter.type; + } = groupBy(filters, ({ type }) => { + return type; }); + const searchQuery = filters?.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + if (filtersByAccount?.length > 0) { where.accountId = { in: filtersByAccount.map(({ id }) => { @@ -358,6 +362,30 @@ export class OrderService { }; } + if (searchQuery) { + const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ + { id: { mode: 'insensitive', startsWith: searchQuery } }, + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + OR: searchQueryWhereInput + } + ] + }; + } else { + where.SymbolProfile = { + OR: searchQueryWhereInput + }; + } + } + if (filtersByTag?.length > 0) { where.tags = { some: { diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ef0868702..f70f5ec3d 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -368,6 +368,12 @@ export abstract class PortfolioCalculator { } = {}; for (const item of lastTransactionPoint.items) { + const feeInBaseCurrency = item.fee.mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + lastTransactionPoint.date + ] + ); + const marketPriceInBaseCurrency = ( marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice ).mul( @@ -431,10 +437,11 @@ export abstract class PortfolioCalculator { }; positions.push({ - dividend: totalDividend, - dividendInBaseCurrency: totalDividendInBaseCurrency, + feeInBaseCurrency, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 340f16b87..3eb166d2e 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.04408677396780965649'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts index 53ebdf19f..a3adde84b 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts index bab265887..6221e6240 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts @@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('1.55'), + feeInBaseCurrency: new Big('1.55'), firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index eba5d4674..85d39220a 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), + feeInBaseCurrency: new Big('0'), firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts index 88d7adb71..7e9bbc26d 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts @@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('49'), + feeInBaseCurrency: new Big('49'), firstBuyDate: '2021-09-01', grossPerformance: null, grossPerformancePercentage: null, diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts index 690f1eb51..31182c8cb 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts @@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('1'), + feeInBaseCurrency: new Big('0.9238'), firstBuyDate: '2023-01-03', grossPerformance: new Big('27.33'), grossPerformancePercentage: new Big('0.3066651705565529623'), @@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => { valueInBaseCurrency: new Big('103.10483') } ], - totalFeesWithCurrencyEffect: new Big('1'), + totalFeesWithCurrencyEffect: new Big('0.9238'), totalInterestWithCurrencyEffect: new Big('0'), totalInvestment: new Big('89.12'), totalInvestmentWithCurrencyEffect: new Big('82.329056'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts index 422d119b2..985dc5feb 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts @@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), + feeInBaseCurrency: new Big('0'), firstBuyDate: '2022-01-01', grossPerformance: null, grossPerformancePercentage: null, diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index f65d2ba61..72cb16a5f 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('4.25'), + feeInBaseCurrency: new Big('4.25'), firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index 902f710ee..59cc13f0e 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => { dividend: new Big('0'), dividendInBaseCurrency: new Big('0'), fee: new Big('0'), + feeInBaseCurrency: new Big('0'), firstBuyDate: '2022-03-07', grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 61f5fe317..8035faa20 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { - if (currentPosition.fee) { + if (currentPosition.feeInBaseCurrency) { totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( - currentPosition.fee + currentPosition.feeInBaseCurrency ); } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index ce0ec5524..02a65b6a0 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -237,6 +237,7 @@ export class UserService { currentPermissions = without( currentPermissions, + permissions.accessHoldingsChart, permissions.createAccess ); diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 5141bf9fa..3b99adb06 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -1,11 +1,21 @@ 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 { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; +import { + PortfolioPosition, + UniqueAsset, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { HoldingType, ToggleOption } from '@ghostfolio/common/types'; +import { + HoldingType, + HoldingViewMode, + ToggleOption +} from '@ghostfolio/common/types'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -18,6 +28,7 @@ import { takeUntil } from 'rxjs/operators'; export class HomeHoldingsComponent implements OnDestroy, OnInit { public deviceType: string; public hasImpersonationId: boolean; + public hasPermissionToAccessHoldingsChart: boolean; public hasPermissionToCreateOrder: boolean; public holdings: PortfolioPosition[]; public holdingType: HoldingType = 'ACTIVE'; @@ -26,6 +37,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { { label: $localize`Closed`, value: 'CLOSED' } ]; public user: User; + public viewModeFormControl = new FormControl('TABLE'); private unsubscribeSubject = new Subject(); @@ -34,6 +46,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, + private router: Router, private userService: UserService ) {} @@ -53,20 +66,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionToAccessHoldingsChart = hasPermission( + this.user.permissions, + permissions.accessHoldingsChart + ); + this.hasPermissionToCreateOrder = hasPermission( this.user.permissions, permissions.createOrder ); - this.holdings = undefined; - - this.fetchHoldings() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ holdings }) => { - this.holdings = holdings; - - this.changeDetectorRef.markForCheck(); - }); + this.initialize(); this.changeDetectorRef.markForCheck(); } @@ -76,15 +86,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { public onChangeHoldingType(aHoldingType: HoldingType) { this.holdingType = aHoldingType; - this.holdings = undefined; - - this.fetchHoldings() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ holdings }) => { - this.holdings = holdings; + this.initialize(); + } - this.changeDetectorRef.markForCheck(); + public onSymbolClicked({ dataSource, symbol }: UniqueAsset) { + if (dataSource && symbol) { + this.router.navigate([], { + queryParams: { dataSource, symbol, holdingDetailDialog: true } }); + } } public ngOnDestroy() { @@ -104,4 +114,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { range: this.user?.settings?.dateRange }); } + + private initialize() { + this.viewModeFormControl.disable(); + + if ( + this.hasPermissionToAccessHoldingsChart && + this.holdingType === 'ACTIVE' + ) { + this.viewModeFormControl.enable(); + } else if (this.holdingType === 'CLOSED') { + this.viewModeFormControl.setValue('TABLE'); + } + + this.holdings = undefined; + + this.fetchHoldings() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ holdings }) => { + this.holdings = holdings; + + this.changeDetectorRef.markForCheck(); + }); + } } diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index a2bd43636..a2ea30a69 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -6,32 +6,60 @@
-
- -
- - @if (hasPermissionToCreateOrder && holdings?.length > 0) { -
- Manage Activities +
+ @if (user?.settings?.isExperimentalFeatures) { +
+
+ + + + + + + + +
+
+ } +
+
+
+ @if (viewModeFormControl.value === 'CHART') { + + } @else if (viewModeFormControl.value === 'TABLE') { + + @if (hasPermissionToCreateOrder && holdings?.length > 0) { + + } }
diff --git a/apps/client/src/app/components/home-holdings/home-holdings.module.ts b/apps/client/src/app/components/home-holdings/home-holdings.module.ts index f10adeab2..df951c1a8 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.module.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.module.ts @@ -1,9 +1,12 @@ import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart'; 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 { MatButtonToggleModule } from '@angular/material/button-toggle'; import { RouterModule } from '@angular/router'; import { HomeHoldingsComponent } from './home-holdings.component'; @@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component'; declarations: [HomeHoldingsComponent], imports: [ CommonModule, + FormsModule, GfHoldingsTableComponent, GfToggleModule, + GfTreemapChartComponent, MatButtonModule, + MatButtonToggleModule, + ReactiveFormsModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/apps/client/src/app/components/home-holdings/home-holdings.scss b/apps/client/src/app/components/home-holdings/home-holdings.scss index 5d4e87f30..d6d129a39 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.scss +++ b/apps/client/src/app/components/home-holdings/home-holdings.scss @@ -1,3 +1,9 @@ :host { display: block; + + .mat-button-toggle-group { + .mat-button-toggle-appearance-standard { + --mat-standard-button-toggle-height: 1.5rem; + } + } } diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index c369f81bd..66eb37f61 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -196,25 +196,23 @@ />
- @if (hasPermissionToUpdateUserSettings) { -
-
-
Experimental Features
-
- Sneak peek at upcoming functionality -
-
-
- +
+
+
Experimental Features
+
+ Sneak peek at upcoming functionality
- } +
+ +
+
Ghostfolio User ID diff --git a/apps/client/src/assets/site.webmanifest b/apps/client/src/assets/site.webmanifest index 3dc452fe9..8f1eceefb 100644 --- a/apps/client/src/assets/site.webmanifest +++ b/apps/client/src/assets/site.webmanifest @@ -7,10 +7,16 @@ { "sizes": "192x192", "src": "/assets/android-chrome-192x192.png", - "type": "image/png", - "purpose": "any maskable" + "type": "image/png" + }, + { + "purpose": "any", + "sizes": "512x512", + "src": "/assets/android-chrome-512x512.png", + "type": "image/png" }, { + "purpose": "maskable", "sizes": "512x512", "src": "/assets/android-chrome-512x512.png", "type": "image/png" @@ -21,5 +27,5 @@ "short_name": "Ghostfolio", "start_url": "/en/", "theme_color": "#FFFFFF", - "url": "https://www.ghostfol.io" + "url": "https://ghostfol.io" } diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts index 412449590..545891464 100644 --- a/libs/common/src/lib/models/timeline-position.ts +++ b/libs/common/src/lib/models/timeline-position.ts @@ -24,6 +24,10 @@ export class TimelinePosition { @Type(() => Big) fee: Big; + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + feeInBaseCurrency: Big; + firstBuyDate: string; @Transform(transformToBig, { toClassOnly: true }) diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 304c8ba24..1a235f1a9 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -5,6 +5,7 @@ import { Role } from '@prisma/client'; export const permissions = { accessAdminControl: 'accessAdminControl', accessAssistant: 'accessAssistant', + accessHoldingsChart: 'accessHoldingsChart', createAccess: 'createAccess', createAccount: 'createAccount', createAccountBalance: 'createAccountBalance', @@ -47,6 +48,7 @@ export function getPermissions(aRole: Role): string[] { return [ permissions.accessAdminControl, permissions.accessAssistant, + permissions.accessHoldingsChart, permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, @@ -72,11 +74,16 @@ export function getPermissions(aRole: Role): string[] { ]; case 'DEMO': - return [permissions.accessAssistant, permissions.createUserAccount]; + return [ + permissions.accessAssistant, + permissions.accessHoldingsChart, + permissions.createUserAccount + ]; case 'USER': return [ permissions.accessAssistant, + permissions.accessHoldingsChart, permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, diff --git a/libs/common/src/lib/personal-finance-tools.ts b/libs/common/src/lib/personal-finance-tools.ts index 8a20ca15c..af7879581 100644 --- a/libs/common/src/lib/personal-finance-tools.ts +++ b/libs/common/src/lib/personal-finance-tools.ts @@ -351,6 +351,18 @@ export const personalFinanceTools: Product[] = [ origin: `Italy`, slogan: 'Your Personal Finance Hub' }, + { + founded: 2008, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'pocketsmith', + languages: ['English'], + name: 'PocketSmith', + origin: `New Zealand`, + pricingPerYear: '$120', + region: `Global`, + slogan: 'Know where your money is going' + }, { hasFreePlan: false, hasSelfHostingAbility: false, diff --git a/libs/common/src/lib/types/holding-view-mode.type.ts b/libs/common/src/lib/types/holding-view-mode.type.ts new file mode 100644 index 000000000..50a4e2b29 --- /dev/null +++ b/libs/common/src/lib/types/holding-view-mode.type.ts @@ -0,0 +1 @@ +export type HoldingViewMode = 'CHART' | 'TABLE'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index fc4ddc4bf..65fdfe5f0 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -8,6 +8,7 @@ import type { DateRange } from './date-range.type'; import type { Granularity } from './granularity.type'; import type { GroupBy } from './group-by.type'; import type { HoldingType } from './holding-type.type'; +import type { HoldingViewMode } from './holding-view-mode.type'; import type { MarketAdvanced } from './market-advanced.type'; import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketState } from './market-state.type'; @@ -30,6 +31,7 @@ export type { Granularity, GroupBy, HoldingType, + HoldingViewMode, Market, MarketAdvanced, MarketDataPreset, diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index aa0a6cacd..c60ed3443 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -354,7 +354,7 @@ export class GfPortfolioProportionChartComponent * Color palette, inspired by https://yeun.github.io/open-color */ private getColorPalette() { - // + // TODO: Reuse require('open-color') return [ '#329af0', // blue 5 '#20c997', // teal 5 diff --git a/libs/ui/src/lib/treemap-chart/index.ts b/libs/ui/src/lib/treemap-chart/index.ts new file mode 100644 index 000000000..62f54ac11 --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/index.ts @@ -0,0 +1 @@ +export * from './treemap-chart.component'; diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.html b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html new file mode 100644 index 000000000..c7de5ef4d --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html @@ -0,0 +1,13 @@ +@if (isLoading) { + +} + diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss new file mode 100644 index 000000000..d041372c8 --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss @@ -0,0 +1,4 @@ +:host { + aspect-ratio: 16 / 9; + display: block; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts new file mode 100644 index 000000000..9ee6a7aeb --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -0,0 +1,168 @@ +import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; + +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { DataSource } from '@prisma/client'; +import { ChartConfiguration } from 'chart.js'; +import { LinearScale } from 'chart.js'; +import { Chart } from 'chart.js'; +import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; +import { orderBy } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +const { gray, green, red } = require('open-color'); + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NgxSkeletonLoaderModule], + selector: 'gf-treemap-chart', + standalone: true, + styleUrls: ['./treemap-chart.component.scss'], + templateUrl: './treemap-chart.component.html' +}) +export class GfTreemapChartComponent + implements AfterViewInit, OnChanges, OnDestroy +{ + @Input() cursor: string; + @Input() holdings: PortfolioPosition[]; + + @Output() treemapChartClicked = new EventEmitter(); + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + public chart: Chart<'treemap'>; + public isLoading = true; + + public constructor() { + Chart.register(LinearScale, TreemapController, TreemapElement); + } + + public ngAfterViewInit() { + if (this.holdings) { + this.initialize(); + } + } + + public ngOnChanges() { + if (this.holdings) { + this.initialize(); + } + } + + public ngOnDestroy() { + this.chart?.destroy(); + } + + private initialize() { + this.isLoading = true; + + const data: ChartConfiguration['data'] = { + datasets: [ + { + backgroundColor(ctx) { + const netPerformancePercentWithCurrencyEffect = + ctx.raw._data.netPerformancePercentWithCurrencyEffect; + + if (netPerformancePercentWithCurrencyEffect > 0.03) { + return green[9]; + } else if (netPerformancePercentWithCurrencyEffect > 0.02) { + return green[7]; + } else if (netPerformancePercentWithCurrencyEffect > 0.01) { + return green[5]; + } else if (netPerformancePercentWithCurrencyEffect > 0) { + return green[3]; + } else if (netPerformancePercentWithCurrencyEffect === 0) { + return gray[3]; + } else if (netPerformancePercentWithCurrencyEffect > -0.01) { + return red[3]; + } else if (netPerformancePercentWithCurrencyEffect > -0.02) { + return red[5]; + } else if (netPerformancePercentWithCurrencyEffect > -0.03) { + return red[7]; + } else { + return red[9]; + } + }, + borderRadius: 4, + key: 'allocationInPercentage', + labels: { + align: 'left', + color: ['white'], + display: true, + font: [{ size: 14 }, { size: 11 }, { lineHeight: 2, size: 14 }], + formatter(ctx) { + const netPerformancePercentWithCurrencyEffect = + ctx.raw._data.netPerformancePercentWithCurrencyEffect; + + return [ + ctx.raw._data.name, + ctx.raw._data.symbol, + `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` + ]; + }, + position: 'top' + }, + spacing: 1, + tree: this.holdings + } + ] + }; + + if (this.chartCanvas) { + if (this.chart) { + this.chart.data = data; + this.chart.update(); + } else { + this.chart = new Chart(this.chartCanvas.nativeElement, { + data, + options: { + animation: false, + onClick: (event, activeElements) => { + try { + const dataIndex = activeElements[0].index; + const datasetIndex = activeElements[0].datasetIndex; + + const dataset = orderBy( + event.chart.data.datasets[datasetIndex].tree, + ['allocationInPercentage'], + ['desc'] + ); + + const dataSource: DataSource = dataset[dataIndex].dataSource; + const symbol: string = dataset[dataIndex].symbol; + + this.treemapChartClicked.emit({ dataSource, symbol }); + } catch {} + }, + onHover: (event, chartElement) => { + if (this.cursor) { + event.native.target.style.cursor = chartElement[0] + ? this.cursor + : 'default'; + } + }, + plugins: { + tooltip: { + enabled: false + } + } + }, + type: 'treemap' + }); + } + } + + this.isLoading = false; + } +} diff --git a/package.json b/package.json index fca0a9a0f..9acc17f2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.94.0", + "version": "2.96.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -97,6 +97,7 @@ "cache-manager-redis-store": "2.0.0", "chart.js": "4.2.0", "chartjs-adapter-date-fns": "3.0.0", + "chartjs-chart-treemap": "2.3.1", "chartjs-plugin-annotation": "2.1.2", "chartjs-plugin-datalabels": "2.2.0", "cheerio": "1.0.0-rc.12", @@ -122,6 +123,7 @@ "ngx-markdown": "18.0.0", "ngx-skeleton-loader": "7.0.0", "ngx-stripe": "18.0.0", + "open-color": "1.9.1", "papaparse": "5.3.1", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", diff --git a/yarn.lock b/yarn.lock index 8d6caaef2..e807877b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10071,6 +10071,11 @@ chartjs-adapter-date-fns@3.0.0: resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz#c25f63c7f317c1f96f9a7c44bd45eeedb8a478e5" integrity sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg== +chartjs-chart-treemap@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/chartjs-chart-treemap/-/chartjs-chart-treemap-2.3.1.tgz#b0d27309ee373cb7706cabb262c48c53ffacf710" + integrity sha512-GW+iODLICIJhNZtHbTtaOjCwRIxmXcquXRKDFMsrkXyqyDeSN1aiVfzNNj6Xjy55soopqRA+YfHqjT2S2zF7lQ== + chartjs-plugin-annotation@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/chartjs-plugin-annotation/-/chartjs-plugin-annotation-2.1.2.tgz#8c307c931fda735a1acf1b606ad0e3fd7d96299b" @@ -16757,6 +16762,11 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open-color@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/open-color/-/open-color-1.9.1.tgz#a6e6328f60eff7aa60e3e8fcfa50f53ff3eece35" + integrity sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw== + open@8.4.2, open@^8.0.4, open@^8.0.9, open@^8.4.0: version "8.4.2" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"