diff --git a/CHANGELOG.md b/CHANGELOG.md index f168e482..3fcd7bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support to deactivate rules in the _X-ray_ section (experimental) + ### Fixed - Fixed the currency conversion for fees and values in the dividend import by applying the correct rate based on the activity date diff --git a/apps/api/src/app/portfolio/rules.service.ts b/apps/api/src/app/portfolio/rules.service.ts index 85f7ed55..6b652614 100644 --- a/apps/api/src/app/portfolio/rules.service.ts +++ b/apps/api/src/app/portfolio/rules.service.ts @@ -12,11 +12,8 @@ export class RulesService { aRules: Rule[], aUserSettings: UserSettings ) { - return aRules - .filter((rule) => { - return rule.getSettings(aUserSettings)?.isActive; - }) - .map((rule) => { + return aRules.map((rule) => { + if (rule.getSettings(aUserSettings)?.isActive) { const { evaluation, value } = rule.evaluate( rule.getSettings(aUserSettings) ); @@ -24,9 +21,17 @@ export class RulesService { return { evaluation, value, + isActive: true, key: rule.getKey(), name: rule.getName() }; - }); + } else { + return { + isActive: false, + key: rule.getKey(), + name: rule.getName() + }; + } + }); } } diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 78e6c27a..6ea6d742 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -3,7 +3,8 @@ import type { ColorScheme, DateRange, HoldingsViewMode, - ViewMode + ViewMode, + XRayRulesSettings } from '@ghostfolio/common/types'; import { @@ -102,4 +103,7 @@ export class UpdateUserSettingDto { @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; + + @IsOptional() + xRayRules?: XRayRulesSettings; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 7cd2002b..8be29bbd 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -24,7 +24,7 @@ import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { size } from 'lodash'; +import { merge, size } from 'lodash'; import { DeleteOwnUserDto } from './delete-own-user.dto'; import { UserItem } from './interfaces/user-item.interface'; @@ -144,10 +144,11 @@ export class UserController { ); } - const userSettings: UserSettings = { - ...(this.request.user.Settings.settings), - ...data - }; + const userSettings: UserSettings = merge( + {}, + this.request.user.Settings.settings, + data + ); for (const key in userSettings) { if (userSettings[key] === false || userSettings[key] === null) { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 7aa1dbbe..25ba9cd6 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -197,6 +197,18 @@ export class UserService { (user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; } + // Set default values for X-ray rules + if (!(user.Settings.settings as UserSettings).xRayRules) { + (user.Settings.settings as UserSettings).xRayRules = { + AccountClusterRiskCurrentInvestment: { isActive: true }, + AccountClusterRiskSingleAccount: { isActive: true }, + CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true }, + CurrencyClusterRiskCurrentInvestment: { isActive: true }, + EmergencyFundSetup: { isActive: true }, + FeeRatioInitialInvestment: { isActive: true } + }; + } + let currentPermissions = getPermissions(user.role); if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index 3b1433da..e25bb2f0 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -79,7 +79,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, + isActive: aUserSettings.xRayRules[this.getKey()].isActive, thresholdMax: 0.5 }; } diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index a47895c1..1f61b965 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -36,7 +36,7 @@ export class AccountClusterRiskSingleAccount extends Rule { public getSettings(aUserSettings: UserSettings): RuleSettings { return { - isActive: true + isActive: aUserSettings.xRayRules[this.getKey()].isActive }; } } diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index 372250db..1258eb88 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -65,7 +65,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, + isActive: aUserSettings.xRayRules[this.getKey()].isActive, thresholdMax: 0.5 }; } diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts index 0f56e6e3..0ba7a109 100644 --- a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -35,7 +35,7 @@ export class EmergencyFundSetup extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, + isActive: aUserSettings.xRayRules[this.getKey()].isActive, thresholdMin: 0 }; } diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index ba487f81..09029fd3 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -46,7 +46,7 @@ export class FeeRatioInitialInvestment extends Rule { public getSettings(aUserSettings: UserSettings): Settings { return { baseCurrency: aUserSettings.baseCurrency, - isActive: true, + isActive: aUserSettings.xRayRules[this.getKey()].isActive, thresholdMax: 0.01 }; } diff --git a/apps/client/src/app/components/rule/rule.component.html b/apps/client/src/app/components/rule/rule.component.html index c2f76a32..80b442b7 100644 --- a/apps/client/src/app/components/rule/rule.component.html +++ b/apps/client/src/app/components/rule/rule.component.html @@ -14,12 +14,17 @@ } @else {
@if (rule?.value === true) { - } @else { + } @else if (rule?.isActive === true) { + } @else { + }
} @@ -46,6 +51,27 @@
{{ rule?.name }}
{{ rule?.evaluation }}
+
+ @if (hasPermissionToUpdateUserSettings) { + + + + + } +
} diff --git a/apps/client/src/app/components/rule/rule.component.ts b/apps/client/src/app/components/rule/rule.component.ts index 61514939..da45fd0d 100644 --- a/apps/client/src/app/components/rule/rule.component.ts +++ b/apps/client/src/app/components/rule/rule.component.ts @@ -1,10 +1,13 @@ +import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { ChangeDetectionStrategy, Component, + EventEmitter, Input, - OnInit + OnInit, + Output } from '@angular/core'; @Component({ @@ -14,10 +17,23 @@ import { styleUrls: ['./rule.component.scss'] }) export class RuleComponent implements OnInit { + @Input() hasPermissionToUpdateUserSettings: boolean; @Input() isLoading: boolean; @Input() rule: PortfolioReportRule; + @Output() ruleUpdated = new EventEmitter(); + public constructor() {} public ngOnInit() {} + + public onUpdateRule(rule: PortfolioReportRule) { + const settings: UpdateUserSettingDto = { + xRayRules: { + [rule.key]: { isActive: !rule.isActive } + } + }; + + this.ruleUpdated.emit(settings); + } } diff --git a/apps/client/src/app/components/rule/rule.module.ts b/apps/client/src/app/components/rule/rule.module.ts index 40e49cad..d2cba5b2 100644 --- a/apps/client/src/app/components/rule/rule.module.ts +++ b/apps/client/src/app/components/rule/rule.module.ts @@ -1,5 +1,7 @@ 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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { RuleComponent } from './rule.component'; @@ -7,7 +9,12 @@ import { RuleComponent } from './rule.component'; @NgModule({ declarations: [RuleComponent], exports: [RuleComponent], - imports: [CommonModule, NgxSkeletonLoaderModule], + imports: [ + CommonModule, + MatButtonModule, + MatMenuModule, + NgxSkeletonLoaderModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfRuleModule {} diff --git a/apps/client/src/app/components/rules/rules.component.html b/apps/client/src/app/components/rules/rules.component.html index 5ef18232..31e61bfc 100644 --- a/apps/client/src/app/components/rules/rules.component.html +++ b/apps/client/src/app/components/rules/rules.component.html @@ -1,20 +1,19 @@
- @if (hasPermissionToCreateOrder && rules === null) { - - - - - - } - - @if (rules?.length === 0) { + @if (isLoading) { } + @if (rules !== null && rules !== undefined) { - @for (rule of rules; track rule) { - + @for (rule of rules; track rule.key) { + } }
diff --git a/apps/client/src/app/components/rules/rules.component.ts b/apps/client/src/app/components/rules/rules.component.ts index 9017700c..b8493e7b 100644 --- a/apps/client/src/app/components/rules/rules.component.ts +++ b/apps/client/src/app/components/rules/rules.component.ts @@ -1,6 +1,13 @@ +import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output +} from '@angular/core'; @Component({ selector: 'gf-rules', @@ -9,8 +16,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; styleUrls: ['./rules.component.scss'] }) export class RulesComponent { - @Input() hasPermissionToCreateOrder: boolean; + @Input() hasPermissionToUpdateUserSettings: boolean; + @Input() isLoading: boolean; @Input() rules: PortfolioReportRule[]; + @Output() rulesUpdated = new EventEmitter(); + public constructor() {} + + public onRuleUpdated(event: UpdateUserSettingDto) { + this.rulesUpdated.emit(event); + } } diff --git a/apps/client/src/app/components/rules/rules.module.ts b/apps/client/src/app/components/rules/rules.module.ts index 26ed1d83..c62cbc3b 100644 --- a/apps/client/src/app/components/rules/rules.module.ts +++ b/apps/client/src/app/components/rules/rules.module.ts @@ -1,5 +1,4 @@ import { GfRuleModule } from '@ghostfolio/client/components/rule/rule.module'; -import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -11,13 +10,7 @@ import { RulesComponent } from './rules.component'; @NgModule({ declarations: [RulesComponent], exports: [RulesComponent], - imports: [ - CommonModule, - GfNoTransactionsInfoComponent, - GfRuleModule, - MatButtonModule, - MatCardModule - ], + imports: [CommonModule, GfRuleModule, MatButtonModule, MatCardModule], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfRulesModule {} diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index c33b2c3f..3a24de9c 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -1,7 +1,12 @@ +import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; 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 { PortfolioReportRule, User } from '@ghostfolio/common/interfaces'; +import { + PortfolioReport, + PortfolioReportRule, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; @@ -23,9 +28,10 @@ export class FirePageComponent implements OnDestroy, OnInit { public feeRules: PortfolioReportRule[]; public fireWealth: Big; public hasImpersonationId: boolean; - public hasPermissionToCreateOrder: boolean; public hasPermissionToUpdateUserSettings: boolean; + public inactiveRules: PortfolioReportRule[]; public isLoading = false; + public isLoadingPortfolioReport = false; public user: User; public withdrawalRatePerMonth: Big; public withdrawalRatePerYear: Big; @@ -64,21 +70,6 @@ export class FirePageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService - .fetchPortfolioReport() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((portfolioReport) => { - this.accountClusterRiskRules = - portfolioReport.rules['accountClusterRisk'] || null; - this.currencyClusterRiskRules = - portfolioReport.rules['currencyClusterRisk'] || null; - this.emergencyFundRules = - portfolioReport.rules['emergencyFund'] || null; - this.feeRules = portfolioReport.rules['fees'] || null; - - this.changeDetectorRef.markForCheck(); - }); - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntil(this.unsubscribeSubject)) @@ -92,11 +83,6 @@ export class FirePageComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; - this.hasPermissionToCreateOrder = hasPermission( - this.user.permissions, - permissions.createOrder - ); - this.hasPermissionToUpdateUserSettings = this.user.subscription?.type === 'Basic' ? false @@ -108,6 +94,8 @@ export class FirePageComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); } }); + + this.initializePortfolioReport(); } public onAnnualInterestRateChange(annualInterestRate: number) { @@ -149,6 +137,17 @@ export class FirePageComponent implements OnDestroy, OnInit { }); } + public onRulesUpdated(event: UpdateUserSettingDto) { + this.isLoading = true; + + this.dataService + .putUserSetting(event) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.initializePortfolioReport(); + }); + } + public onSavingsRateChange(savingsRate: number) { this.dataService .putUserSetting({ savingsRate }) @@ -192,4 +191,59 @@ export class FirePageComponent implements OnDestroy, OnInit { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private initializePortfolioReport() { + this.isLoadingPortfolioReport = true; + + this.dataService + .fetchPortfolioReport() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((portfolioReport) => { + this.inactiveRules = this.mergeInactiveRules(portfolioReport); + + this.accountClusterRiskRules = + portfolioReport.rules['accountClusterRisk']?.filter( + ({ isActive }) => { + return isActive; + } + ) ?? null; + + this.currencyClusterRiskRules = + portfolioReport.rules['currencyClusterRisk']?.filter( + ({ isActive }) => { + return isActive; + } + ) ?? null; + + this.emergencyFundRules = + portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => { + return isActive; + }) ?? null; + + this.feeRules = + portfolioReport.rules['fees']?.filter(({ isActive }) => { + return isActive; + }) ?? null; + + this.isLoadingPortfolioReport = false; + + this.changeDetectorRef.markForCheck(); + }); + } + + private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] { + let inactiveRules: PortfolioReportRule[] = []; + + for (const category in report.rules) { + const rulesArray = report.rules[category]; + + inactiveRules = inactiveRules.concat( + rulesArray.filter(({ isActive }) => { + return !isActive; + }) + ); + } + + return inactiveRules; + } } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 7c8e09ee..b0fade83 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -125,8 +125,14 @@ }
@@ -137,8 +143,14 @@ }
@@ -149,11 +161,17 @@ }
-
+

Fees @if (user?.subscription?.type === 'Basic') { @@ -161,10 +179,31 @@ }

+ @if (inactiveRules?.length > 0) { +
+

Inactive

+ +
+ }
diff --git a/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts b/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts index 5ba8ccef..61fe389a 100644 --- a/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts @@ -1,5 +1,7 @@ export interface PortfolioReportRule { - evaluation: string; + evaluation?: string; + isActive: boolean; + key: string; name: string; - value: boolean; + value?: boolean; } diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 5c88e3f4..106f5460 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -2,7 +2,8 @@ import { ColorScheme, DateRange, HoldingsViewMode, - ViewMode + ViewMode, + XRayRulesSettings } from '@ghostfolio/common/types'; export interface UserSettings { @@ -23,4 +24,5 @@ export interface UserSettings { retirementDate?: string; savingsRate?: number; viewMode?: ViewMode; + xRayRules?: XRayRulesSettings; } diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 68d4a2ec..f8307152 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -19,6 +19,7 @@ import type { SubscriptionOffer } from './subscription-offer.type'; import type { ToggleOption } from './toggle-option.type'; import type { UserWithSettings } from './user-with-settings.type'; import type { ViewMode } from './view-mode.type'; +import type { XRayRulesSettings } from './x-ray-rules-settings.type'; export type { AccessType, @@ -41,5 +42,6 @@ export type { SubscriptionOffer, ToggleOption, UserWithSettings, - ViewMode + ViewMode, + XRayRulesSettings }; diff --git a/libs/common/src/lib/types/x-ray-rules-settings.type.ts b/libs/common/src/lib/types/x-ray-rules-settings.type.ts new file mode 100644 index 00000000..bc278254 --- /dev/null +++ b/libs/common/src/lib/types/x-ray-rules-settings.type.ts @@ -0,0 +1,12 @@ +export type XRayRulesSettings = { + AccountClusterRiskCurrentInvestment?: RuleSettings; + AccountClusterRiskSingleAccount?: RuleSettings; + CurrencyClusterRiskBaseCurrencyCurrentInvestment?: RuleSettings; + CurrencyClusterRiskCurrentInvestment?: RuleSettings; + EmergencyFundSetup?: RuleSettings; + FeeRatioInitialInvestment?: RuleSettings; +}; + +interface RuleSettings { + isActive: boolean; +}