Browse Source

Merge branch 'main' into feature/benchmark-comparator-support-for-later-benchmark-start

pull/8106/head
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
46218a5ad6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 27
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 2
      apps/client/src/app/app.component.html
  4. 8
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  5. 18
      apps/client/src/app/components/footer/footer.component.html
  6. 80
      apps/client/src/app/components/footer/footer.component.ts
  7. 10
      apps/client/src/app/components/header/header.component.html
  8. 56
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  9. 10
      apps/client/src/app/components/home-holdings/home-holdings.html
  10. 8
      apps/client/src/app/components/home-overview/home-overview.component.ts
  11. 13
      apps/client/src/app/components/home-overview/home-overview.html
  12. 23
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  13. 14
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  14. 4
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  15. 8
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  16. 44
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  17. 29
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  18. 7
      apps/client/src/app/util/date.util.ts
  19. 2
      libs/ui/src/lib/line-chart/line-chart.component.ts
  20. 7
      libs/ui/src/lib/services/data.service.ts

2
CHANGELOG.md

@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the performance timeline grouped by year to the analysis page (experimental)
- Extended the `GET api/v2/portfolio/performance` endpoint by the `groupBy` query parameter (`year`)
### Changed
- Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio
- Harmonized the colors in the investment chart component
- Improved the language localization for Chinese (`zh`)
## 3.82.0 - 2026-10-09

27
apps/api/src/app/portfolio/portfolio.controller.ts

@ -4,7 +4,8 @@ import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorato
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
@ -583,14 +584,22 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Basic
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
return nullifyValuesInObject(item, [
'dividendInBaseCurrency',
'totalInvestment',
'value'
]);
}
performanceInformation.chart = nullifyValuesInObjects(
performanceInformation.chart,
[
'dividendInBaseCurrency',
'totalInvestment',
'value',
...(groupBy === 'year'
? [
'investmentValueWithCurrencyEffect',
'netPerformance',
'netPerformanceInPercentage',
'netPerformanceInPercentageWithCurrencyEffect',
'netPerformanceWithCurrencyEffect'
]
: [])
]
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,

2
apps/client/src/app/app.component.html

@ -48,6 +48,6 @@
@if (showFooter) {
<footer class="justify-content-center overflow-hidden w-100">
<gf-footer class="py-4" [info]="info" [user]="user" />
<gf-footer class="py-4" [info]="info" />
</footer>
}

8
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -28,12 +28,8 @@
matInput
(keydown.enter)="$event.stopPropagation()"
/>
@if (platformForm.get('url')?.value) {
<gf-entity-logo
class="mat-icon"
matSuffix
[url]="platformForm.get('url').value"
/>
@if (platformForm.get('url')?.value; as url) {
<gf-entity-logo class="mat-icon" matSuffix [url]="url" />
}
</mat-form-field>
</div>

18
apps/client/src/app/components/footer/footer.component.html

@ -6,7 +6,7 @@
<div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled">
@if (hasPermissionToAccessFearAndGreedIndex) {
@if (hasPermissionToAccessFearAndGreedIndex()) {
<li>
<a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li>
@ -18,7 +18,7 @@
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkBlog">Blog</a>
</li>
@ -27,43 +27,43 @@
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li>
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li>
}
@if (!hasPermissionForSubscription) {
@if (!hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li>
}
@if (hasPermissionForStatistics) {
@if (hasPermissionForStatistics()) {
<li>
<a [routerLink]="routerLinkOpenStartup">Open Startup</a>
</li>
}
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li>
}
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a
>
</li>
}
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a
>
</li>
}
@if (hasPermissionForSubscription) {
@if (hasPermissionForSubscription()) {
<li>
<a
class="align-items-baseline d-flex"

80
apps/client/src/app/components/footer/footer.component.ts

@ -1,4 +1,4 @@
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
@ -6,9 +6,9 @@ import { GfLogoComponent } from '@ghostfolio/ui/logo';
import {
ChangeDetectionStrategy,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
Input,
OnChanges
input
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
@ -23,51 +23,53 @@ import { openOutline } from 'ionicons/icons';
styleUrls: ['./footer.component.scss'],
templateUrl: './footer.component.html'
})
export class GfFooterComponent implements OnChanges {
@Input() public info: InfoItem;
@Input() public user: User;
export class GfFooterComponent {
public readonly info = input.required<InfoItem | undefined>();
public currentYear = new Date().getFullYear();
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public constructor() {
addIcons({
openOutline
});
}
protected readonly currentYear = new Date().getFullYear();
public ngOnChanges() {
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
protected readonly hasPermissionForStatistics = computed(() => {
return hasPermission(
this.info()?.globalPermissions,
permissions.enableStatistics
);
});
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
protected readonly hasPermissionForSubscription = computed(() => {
return hasPermission(
this.info()?.globalPermissions,
permissions.enableSubscription
);
});
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
protected readonly hasPermissionToAccessFearAndGreedIndex = computed(() => {
return hasPermission(
this.info()?.globalPermissions,
permissions.enableFearAndGreedIndex
);
});
protected readonly routerLinkAbout = publicRoutes.about.routerLink;
protected readonly routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
protected readonly routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
protected readonly routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
protected readonly routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
protected readonly routerLinkBlog = publicRoutes.blog.routerLink;
protected readonly routerLinkFaq = publicRoutes.faq.routerLink;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkMarkets = publicRoutes.markets.routerLink;
protected readonly routerLinkOpenStartup =
publicRoutes.openStartup.routerLink;
protected readonly routerLinkPricing = publicRoutes.pricing.routerLink;
protected readonly routerLinkResources = publicRoutes.resources.routerLink;
public constructor() {
addIcons({
openOutline
});
}
}

10
apps/client/src/app/components/header/header.component.html

@ -124,7 +124,7 @@
>About</a
>
</li>
@if (hasPermissionToAccessAssistant) {
@if (hasPermissionToAccessAssistant && user(); as user) {
<li class="d-inline-flex list-inline-item">
<button
#assistantTrigger="matMenuTrigger"
@ -144,7 +144,7 @@
class="no-max-width"
xPosition="before"
[overlapTrigger]="true"
(closed)="assistantElement()?.setIsOpen(false)"
(closed)="assistantElement().setIsOpen(false)"
>
<gf-assistant
#assistant
@ -156,7 +156,7 @@
hasPermissionToChangeDateRange()
"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters()"
[user]="user()"
[user]="user"
(closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)"
(filtersChanged)="onFiltersChanged($event)"
@ -191,7 +191,7 @@
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex"
><span>
@if (user().subscription.offer.isRenewal) {
@if (user()?.subscription?.offer?.isRenewal) {
<ng-container i18n>Renew Plan</ng-container>
} @else {
<ng-container i18n>Upgrade Plan</ng-container>
@ -203,7 +203,7 @@
></a>
<hr class="m-0" />
}
@if (user()?.access?.length > 0) {
@if ((user()?.access?.length ?? 0) > 0) {
<button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex">
<ion-icon

56
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -1,5 +1,9 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LOCALE
} from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
PortfolioPosition,
@ -22,10 +26,12 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
inject,
OnInit
OnInit,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -51,9 +57,22 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly baseCurrency = computed(() => {
return this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY;
});
protected readonly colorScheme = computed(() => {
return this.user()?.settings?.colorScheme;
});
protected readonly dateRange = computed(() => {
return this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE;
});
protected readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
protected deviceType: string;
protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined;
@ -78,9 +97,14 @@ export class GfHomeHoldingsComponent implements OnInit {
{ label: $localize`Closed`, value: 'CLOSED' }
];
protected isHoldingsViewModeToggleDisabled = true;
protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
});
protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink;
protected user: User;
protected readonly user = signal<User | null>(null);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
@ -94,22 +118,22 @@ export class GfHomeHoldingsComponent implements OnInit {
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
const user = state.user;
this.user.set(user);
this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions,
user.permissions,
permissions.accessHoldingsChart
);
this.hasPermissionToCreateActivity =
hasPermission(this.user.permissions, permissions.createActivity) &&
hasScope(this.user.scopes, scopes.activityCreate);
hasPermission(user.permissions, permissions.createActivity) &&
hasScope(user.scopes, scopes.activityCreate);
this.initialize();
}
@ -129,7 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
this.user.set(user);
this.changeDetectorRef.markForCheck();
});
@ -144,7 +168,7 @@ export class GfHomeHoldingsComponent implements OnInit {
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
this.router.navigate([], {
void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}
@ -157,7 +181,7 @@ export class GfHomeHoldingsComponent implements OnInit {
return this.dataService.fetchPortfolioHoldings({
filters,
range: this.user?.settings?.dateRange
range: this.user()?.settings?.dateRange
});
}
@ -171,9 +195,9 @@ export class GfHomeHoldingsComponent implements OnInit {
this.isHoldingsViewModeToggleDisabled = false;
this.holdingsViewMode =
this.deviceType === 'mobile'
this.deviceType() === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: (this.user?.settings?.holdingsViewMode ??
: (this.user()?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE);
} else {
this.holdingsViewMode =

10
apps/client/src/app/components/home-holdings/home-holdings.html

@ -27,18 +27,18 @@
<gf-treemap-chart
class="mt-3"
cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[dateRange]="user?.settings?.dateRange"
[baseCurrency]="baseCurrency()"
[colorScheme]="colorScheme()"
[dateRange]="dateRange()"
[holdings]="holdings"
[locale]="user?.settings?.locale"
[locale]="locale()"
(treemapChartClicked)="onHoldingClicked($event)"
/>
}
<div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table
[holdings]="holdings"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[locale]="locale()"
(holdingClicked)="onHoldingClicked($event)"
/>
@if (hasPermissionToCreateActivity && (holdings?.length ?? 0) > 0) {

8
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -53,7 +53,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html'
})
export class GfHomeOverviewComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
@ -61,6 +60,11 @@ export class GfHomeOverviewComponent implements OnInit {
undefined
);
protected readonly isLoadingPerformance = signal(true);
protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
});
protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`;
protected readonly precision = signal(2);
@ -147,7 +151,7 @@ export class GfHomeOverviewComponent implements OnInit {
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
this.router.navigate([], {
void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}

13
apps/client/src/app/components/home-overview/home-overview.html

@ -11,7 +11,10 @@
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li class="mb-2" [class.text-muted]="user()?.accounts?.length > 1">
<li
class="mb-2"
[class.text-muted]="(user()?.accounts?.length ?? 0) > 1"
>
<a class="d-block" [routerLink]="routerLinkAccounts"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
@ -48,7 +51,7 @@
>
<ng-container i18n>Setup accounts</ng-container>
</a>
} @else if (user()?.accounts?.length > 1) {
} @else if ((user()?.accounts?.length ?? 0) > 1) {
<a
color="primary"
mat-flat-button
@ -73,7 +76,7 @@
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel"
[locale]="user()?.settings?.locale"
[locale]="locale()"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
@ -85,7 +88,7 @@
<gf-portfolio-performance
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale"
[locale]="locale()"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
@ -107,7 +110,7 @@
<gf-holdings-table
mode="simple"
[holdings]="holdings()"
[locale]="user()?.settings?.locale ?? DEFAULT_LOCALE"
[locale]="locale()"
(holdingClicked)="onHoldingClicked($event)"
/>
</mat-card-content>

23
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -113,10 +113,15 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}
private initialize() {
// Create a clone
const isBarChart = !!this.groupBy;
const benchmarkColorRgb = isBarChart ? primaryColorRgb : secondaryColorRgb;
// Create clones of the input data
this.investments = this.benchmarkDataItems.map((item) =>
Object.assign({}, item)
);
this.values = this.historicalDataItems.map((item) =>
Object.assign({}, item)
);
@ -127,9 +132,9 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}),
datasets: [
{
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 1,
backgroundColor: `rgb(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b})`,
borderColor: `rgb(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b})`,
borderWidth: isBarChart ? 0 : 1,
data: this.investments.map(({ date, investment }) => {
return {
x: parseDate(date)?.getTime() ?? null,
@ -141,7 +146,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
borderColor: (context) =>
this.isInFuture(
context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
`rgba(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b}, 0.67)`
),
borderDash: (context) => this.isInFuture(context, [2, 2])
},
@ -200,10 +205,10 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`,
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: $localize`Savings Rate`,
@ -234,7 +239,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
responsive: true,
scales: {
x: getTimeAxisOptions({
borderWidth: this.groupBy ? 0 : 1,
borderWidth: isBarChart ? 0 : 1,
colorScheme: this.colorScheme,
locale: this.locale
}),
@ -250,7 +255,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
plugins: [
getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme)
],
type: this.groupBy ? 'bar' : 'line'
type: isBarChart ? 'bar' : 'line'
}
);
}

14
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -35,9 +35,9 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
})
export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>();
public readonly isLoading = input<boolean>(false);
public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance>();
public readonly performance = input.required<PortfolioPerformance | null>();
public readonly precision = input.required<number, number>({
transform: (value) => {
return value >= 0 ? value : 2;
@ -60,17 +60,19 @@ export class GfPortfolioPerformanceComponent {
this.value().nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance().currentValueInBaseCurrency)) {
new CountUp('value', this.performance().currentValueInBaseCurrency, {
const performance = this.performance();
if (isNumber(performance?.currentValueInBaseCurrency)) {
new CountUp('value', performance.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(),
duration: 1,
separator: getNumberFormatGroup(this.locale())
}).start();
} else if (this.showDetails() === false) {
} else if (this.showDetails() === false && performance) {
new CountUp(
'value',
this.performance().netPerformancePercentageWithCurrencyEffect * 100,
performance.netPerformancePercentageWithCurrencyEffect * 100,
{
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: 2,

4
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -3,7 +3,9 @@
<div class="col">
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[expiresAt]="
(user?.subscription?.expiresAt | date: defaultDateFormat) ?? ''
"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[hover3d]="true"
[name]="user?.subscription?.type"

8
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -43,7 +43,7 @@
}}</span>
</mat-form-field>
</div>
<div [class.d-none]="platforms?.length < 1">
<div [class.d-none]="platforms.length < 1">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
@if (selectedPlatform) {
@ -51,8 +51,8 @@
class="ml-3"
matPrefix
[hasPlaceholder]="true"
[tooltip]="selectedPlatform?.name"
[url]="selectedPlatform?.url"
[tooltip]="selectedPlatform.name ?? ''"
[url]="selectedPlatform.url"
/>
}
<input
@ -73,7 +73,7 @@
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="platformEntry.name"
[tooltip]="platformEntry.name ?? ''"
[url]="platformEntry.url"
/>
<span>{{ platformEntry.name }}</span>

44
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -5,6 +5,7 @@ import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import {
InvestmentItem,
@ -93,6 +94,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected isLoadingDividendTimelineChart: boolean;
protected isLoadingInvestmentChart: boolean;
protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPerformanceTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean;
protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption<GroupBy>[] = [
@ -103,7 +105,9 @@ export class GfAnalysisPageComponent implements OnInit {
protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected performanceDataItems: LineChartItem[];
protected performanceDataItemsInPercentage: LineChartItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
protected performancePercentagesByYear: InvestmentItem[];
protected readonly performanceTimelineDataLabel = $localize`Net Performance`;
protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`;
protected precision = 2;
protected savingsRatePerMonth: number | undefined;
protected streaks: PortfolioInvestmentsResponse['streaks'];
@ -312,6 +316,37 @@ export class GfAnalysisPageComponent implements OnInit {
);
}
private fetchPerformanceByYear() {
this.isLoadingPerformanceTimelineChart = true;
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),
groupBy: 'year',
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart }) => {
this.performancePercentagesByYear = [];
for (const {
date,
netPerformanceInPercentageWithCurrencyEffect
} of chart ?? []) {
if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) {
this.performancePercentagesByYear.push({
date,
investment: netPerformanceInPercentageWithCurrencyEffect
});
}
}
this.isLoadingPerformanceTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
}
private update() {
this.isLoadingInvestmentChart = true;
@ -420,6 +455,13 @@ export class GfAnalysisPageComponent implements OnInit {
this.fetchDividendsAndInvestments();
if (
this.user?.settings?.isExperimentalFeatures &&
this.user?.subscription?.type !== SubscriptionType.Basic
) {
this.fetchPerformanceByYear();
}
this.changeDetectorRef.markForCheck();
}

29
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -430,6 +430,35 @@
</div>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-5 row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Performance Timeline</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
groupBy="year"
[benchmarkDataItems]="performancePercentagesByYear"
[benchmarkDataLabel]="performanceTimelineDataLabel"
[colorScheme]="user?.settings?.colorScheme"
[isInPercentage]="true"
[isLoading]="isLoadingPerformanceTimelineChart"
[locale]="user?.settings?.locale"
/>
</div>
</div>
</div>
}
<div class="mb-5 row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">

7
apps/client/src/app/util/date.util.ts

@ -10,16 +10,17 @@ export function formatRelativeTime({
date,
language
}: {
date: string;
date: Date | string | null | undefined;
language?: string;
}) {
if (date) {
const distanceString = formatDistanceToNowStrict(parseISO(date), {
const parsedDate = typeof date === 'string' ? parseISO(date) : date;
const distanceString = formatDistanceToNowStrict(parsedDate, {
addSuffix: true,
locale: getDateFnsLocale(language)
});
return Math.abs(differenceInSeconds(parseISO(date), new Date())) < 60
return Math.abs(differenceInSeconds(parsedDate, new Date())) < 60
? $localize`just now`
: distanceString;
}

2
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -60,7 +60,7 @@ export class GfLineChartComponent
@Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme;
@Input() currency?: string;
@Input() historicalDataItems: LineChartItem[];
@Input() historicalDataItems: LineChartItem[] | null;
@Input() isAnimated = false;
@Input() label: string;
@Input() locale = getLocale();

7
libs/ui/src/lib/services/data.service.ts

@ -728,14 +728,21 @@ export class DataService {
public fetchPortfolioPerformance({
filters,
groupBy,
range,
withExcludedAccounts = false
}: {
filters?: Filter[];
groupBy?: Extract<GroupBy, 'year'>;
range: DateRange;
withExcludedAccounts?: boolean;
}): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters });
if (groupBy) {
params = params.append('groupBy', groupBy);
}
params = params.append('range', range);
if (withExcludedAccounts) {

Loading…
Cancel
Save