Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror

This commit is contained in:
ksyasuda 2024-08-21 11:35:50 -07:00
commit 099896f46b
36 changed files with 2387 additions and 1572 deletions

View File

@ -5,7 +5,15 @@ 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).
## Unreleased
## 2.105.0 - 2024-08-21
### Added
- Added support to deactivate rules in the _X-ray_ section (experimental)
### Changed
- Improved the language localization for German (`de`)
### Fixed

View File

@ -12,11 +12,8 @@ export class RulesService {
aRules: Rule<T>[],
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()
};
}
});
}
}

View File

@ -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(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional()
viewMode?: ViewMode;
@IsOptional()
xRayRules?: XRayRulesSettings;
}

View File

@ -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 = {
...(<UserSettings>this.request.user.Settings.settings),
...data
};
const userSettings: UserSettings = merge(
{},
<UserSettings>this.request.user.Settings.settings,
data
);
for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) {

View File

@ -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) {

View File

@ -79,7 +79,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.5
};
}

View File

@ -36,7 +36,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public getSettings(aUserSettings: UserSettings): RuleSettings {
return {
isActive: true
isActive: aUserSettings.xRayRules[this.getKey()].isActive
};
}
}

View File

@ -65,7 +65,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
isActive: aUserSettings.xRayRules[this.getKey()].isActive
};
}
}

View File

@ -65,7 +65,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.5
};
}

View File

@ -35,7 +35,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMin: 0
};
}

View File

@ -46,7 +46,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
isActive: aUserSettings.xRayRules[this.getKey()].isActive,
thresholdMax: 0.01
};
}

View File

@ -14,12 +14,17 @@
} @else {
<div
class="align-items-center d-flex icon-container mr-2 px-2"
[ngClass]="{ okay: rule?.value === true, warn: rule?.value === false }"
[ngClass]="{
okay: rule?.value === true,
warn: rule?.value === false
}"
>
@if (rule?.value === true) {
<ion-icon name="checkmark-circle-outline" />
} @else {
} @else if (rule?.isActive === true) {
<ion-icon name="warning-outline" />
} @else {
<ion-icon class="text-muted" name="remove-circle-outline" />
}
</div>
}
@ -46,6 +51,27 @@
<div class="h6 my-1">{{ rule?.name }}</div>
<div class="evaluation">{{ rule?.evaluation }}</div>
</div>
<div>
@if (hasPermissionToUpdateUserSettings) {
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="rulesMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #rulesMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateRule(rule)">
@if (rule?.isActive) {
<ng-container i18n>Deactivate</ng-container>
} @else {
<ng-container i18n>Activate</ng-container>
}
</button>
</mat-menu>
}
</div>
}
</div>
</div>

View File

@ -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<UpdateUserSettingDto>();
public constructor() {}
public ngOnInit() {}
public onUpdateRule(rule: PortfolioReportRule) {
const settings: UpdateUserSettingDto = {
xRayRules: {
[rule.key]: { isActive: !rule.isActive }
}
};
this.ruleUpdated.emit(settings);
}
}

View File

@ -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 {}

View File

@ -1,20 +1,19 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
@if (hasPermissionToCreateOrder && rules === null) {
<mat-card appearance="outlined" class="my-2 text-center">
<mat-card-content>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</mat-card-content>
</mat-card>
}
@if (rules?.length === 0) {
@if (isLoading) {
<gf-rule [isLoading]="true" />
}
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule) {
<gf-rule [rule]="rule" />
@for (rule of rules; track rule.key) {
<gf-rule
[hasPermissionToUpdateUserSettings]="
hasPermissionToUpdateUserSettings
"
[rule]="rule"
(ruleUpdated)="onRuleUpdated($event)"
/>
}
}
</div>

View File

@ -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<UpdateUserSettingDto>();
public constructor() {}
public onRuleUpdated(event: UpdateUserSettingDto) {
this.rulesUpdated.emit(event);
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -125,8 +125,14 @@
}
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
@ -137,8 +143,14 @@
}
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
@ -149,11 +161,17 @@
}
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
@ -161,10 +179,31 @@
}
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
export interface PortfolioReportRule {
evaluation: string;
evaluation?: string;
isActive: boolean;
key: string;
name: string;
value: boolean;
value?: boolean;
}

View File

@ -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;
}

View File

@ -668,5 +668,15 @@ export const personalFinanceTools: Product[] = [
origin: 'United States',
pricingPerYear: '$99',
slogan: 'Change Your Relationship With Money'
},
{
founded: 2019,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'ziggma',
name: 'Ziggma',
origin: 'United States',
pricingPerYear: '$90',
slogan: 'Your solution for investing success'
}
];

View File

@ -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
};

View File

@ -0,0 +1,12 @@
export type XRayRulesSettings = {
AccountClusterRiskCurrentInvestment?: RuleSettings;
AccountClusterRiskSingleAccount?: RuleSettings;
CurrencyClusterRiskBaseCurrencyCurrentInvestment?: RuleSettings;
CurrencyClusterRiskCurrentInvestment?: RuleSettings;
EmergencyFundSetup?: RuleSettings;
FeeRatioInitialInvestment?: RuleSettings;
};
interface RuleSettings {
isActive: boolean;
}

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.104.1",
"version": "2.105.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",