Browse Source

Merge branch 'feature/asset-profiles-read-endpoint' of https://github.com/Sjohn21/ghostfolio into pr/7056

pull/7056/head
Thomas Kaul 4 weeks ago
parent
commit
5c6c92338f
  1. 47
      CHANGELOG.md
  2. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 20
      apps/api/src/app/user/user.service.ts
  4. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  5. 1
      apps/api/src/services/configuration/configuration.service.ts
  6. 1
      apps/api/src/services/interfaces/environment.interface.ts
  7. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  8. 3
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  9. 3
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  10. 23
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  11. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  12. 8
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  13. 13
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  14. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  15. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  16. 3
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  17. 4
      apps/client/src/app/components/admin-users/admin-users.component.ts
  18. 6
      apps/client/src/app/components/admin-users/admin-users.html
  19. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  20. 213
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  21. 3
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  22. 4
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  23. 5
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  24. 30
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  25. 262
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  26. 4
      apps/client/src/app/components/rules/rules.component.html
  27. 2
      apps/client/src/app/pages/about/overview/about-overview-page.html
  28. 63
      apps/client/src/app/pages/admin/admin-page.component.ts
  29. 2
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  30. 2
      apps/client/src/app/pages/faq/saas/saas-page.html
  31. 2
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  32. 5
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  33. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  34. 11
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  35. 4
      apps/client/src/app/pages/portfolio/fire/fire-page.scss
  36. 14
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  37. 10
      apps/client/src/app/pages/pricing/pricing-page.html
  38. 8
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  39. 2
      apps/client/src/locales/messages.de.xlf
  40. 22
      apps/client/src/locales/messages.es.xlf
  41. 3
      apps/client/src/main.ts
  42. 8
      apps/client/src/styles/theme.scss
  43. 4
      libs/common/src/lib/config.ts
  44. 20
      libs/common/src/lib/helper.ts
  45. 8
      libs/common/src/lib/interfaces/index.ts
  46. 6
      libs/common/src/lib/interfaces/line-chart-item.interface.ts
  47. 3
      libs/common/src/lib/interfaces/referral-partner.interface.ts
  48. 16
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  49. 2
      libs/common/src/lib/interfaces/user.interface.ts
  50. 79
      libs/common/src/lib/personal-finance-tools.ts
  51. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  52. 12
      libs/ui/src/lib/activities-table/activities-table.component.html
  53. 7
      libs/ui/src/lib/chart/chart.registry.ts
  54. 4
      libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
  55. 6
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts
  56. 9
      libs/ui/src/lib/page-tabs/interfaces/interfaces.ts
  57. 49
      libs/ui/src/lib/page-tabs/page-tabs.component.html
  58. 43
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  59. 3
      libs/ui/src/lib/page-tabs/page-tabs.component.ts
  60. 5
      libs/ui/src/lib/portfolio-proportion-chart/interfaces/interfaces.ts
  61. 18
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  62. 25
      libs/ui/src/lib/services/data.service.ts
  63. 2
      libs/ui/src/lib/world-map-chart/world-map-chart.component.ts
  64. 68
      package-lock.json
  65. 12
      package.json

47
CHANGELOG.md

@ -10,6 +10,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
### Fixed
- Fixed an issue with the localization of the country names
## 3.12.0 - 2026-06-17
### Changed
- Improved the styling of the checkboxes to consistently use the primary color in their states
- Improved the account name display in the accounts table
- Improved the name display in the activities table
- Improved the last activity display in the users table of the admin control panel
- Improved the registration display in the users table of the admin control panel
- Improved the user id display in the users table of the admin control panel
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `svgmap` from version `2.19.3` to `2.21.0`
### Fixed
- Fixed a chart error on interaction by registering the annotation plugin early
- Fixed an issue on the allocations page where clicking an account in the _By Account_ chart did not open the detail dialog
- Restricted the maximum height of the import activities dialog
- Fixed the dark mode styling of the safe withdrawal rate selector in the _FIRE_ section (experimental)
## 3.11.0 - 2026-06-14
### Added
- Added support for a click handler in the page tabs component
### Changed
- Improved the styling of the tabs across various dialogs
- Improved the styling of the page tabs component on desktop
- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental)
- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control
- Improved the language localization for Spanish (`es`)
- Upgraded `bull-board` from version `7.1.5` to `7.2.1`
- Upgraded `date-fns` from version `4.1.0` to `4.4.0`
### Fixed
- Improved the loading state when customizing the rule thresholds on the _X-ray_ page
## 3.10.0 - 2026-06-13

12
apps/api/src/app/portfolio/portfolio.service.ts

@ -957,6 +957,18 @@ export class PortfolioService {
marketPriceMin,
SymbolProfile,
tags,
assetProfile: {
assetClass: SymbolProfile.assetClass,
assetSubClass: SymbolProfile.assetSubClass,
countries: SymbolProfile.countries,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
isin: SymbolProfile.isin,
name: SymbolProfile.name,
sectors: SymbolProfile.sectors,
symbol: SymbolProfile.symbol,
userId: SymbolProfile.userId
},
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),

20
apps/api/src/app/user/user.service.ts

@ -28,14 +28,16 @@ import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale as defaultLocale
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
User as IUser,
ReferralPartner,
SystemMessage,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -100,7 +102,7 @@ export class UserService {
public async getUser({
impersonationUserId,
locale = defaultLocale,
locale = DEFAULT_LOCALE,
user
}: {
impersonationUserId: string;
@ -153,6 +155,17 @@ export class UserService {
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
(settings.settings as UserSettings)?.baseCurrency;
let referralPartners: ReferralPartner[];
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
) {
referralPartners = await this.propertyService.getByKey<ReferralPartner[]>(
PROPERTY_REFERRAL_PARTNERS
);
}
let systemMessage: SystemMessage;
const systemMessageProperty =
@ -179,6 +192,7 @@ export class UserService {
activitiesCount,
id,
permissions,
referralPartners,
subscription,
systemMessage,
tags,

1
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -64,6 +64,7 @@ export class TransformDataSourceInResponseInterceptor<
paths: [
'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',

1
apps/api/src/services/configuration/configuration.service.ts

@ -30,7 +30,6 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;

3
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -23,8 +23,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
displayName: 'Data Gathering'
}
}),
BullModule.registerQueue({

3
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
displayName: 'Portfolio Snapshot Computation'
}
}),
BullModule.registerQueue({

3
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -20,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE,
options: {
displayName: 'Statistics Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
displayName: 'Statistics Gathering'
}
})
]

23
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -1,8 +1,5 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -10,7 +7,6 @@ import {
} from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services';
@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit {
'actions'
];
protected hasPermissionToAccessBullBoard = false;
protected isLoading = false;
protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() {
@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale
);
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
}
});
@ -193,18 +182,6 @@ export class GfAdminJobsComponent implements OnInit {
});
}
protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({
title: JSON.stringify(aData, null, ' ')

9
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -1,15 +1,6 @@
<div class="container">
<div class="row">
<div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status">

8
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE,
locale
DEFAULT_LOCALE,
DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config';
import {
canDeleteAssetProfile,
@ -429,7 +429,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -464,7 +464,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});

13
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -239,7 +239,18 @@
[disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()"
>
<ng-container i18n>Delete Profiles</ng-container>
<ng-container i18n
>Delete
{{
selection.selected.length > 1
? selection.selected.length
: ''
}}
{selection.selected.length, plural,
=1 {Profile}
other {Profiles}
}</ng-container
>
</button>
</mat-menu>
</th>

12
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -32,6 +32,8 @@ export class AdminMarketDataService {
public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
) {
const assetProfileCount = aAssetProfileIdentifiers.length;
this.notificationService.confirm({
confirmFn: () => {
const deleteRequests = aAssetProfileIdentifiers.map(
@ -44,7 +46,10 @@ export class AdminMarketDataService {
.pipe(
catchError(() => {
this.notificationService.alert({
title: $localize`Oops! Could not delete profiles.`
title:
assetProfileCount === 1
? $localize`Oops! Could not delete the asset profile.`
: $localize`Oops! Could not delete the asset profiles.`
});
return EMPTY;
@ -56,7 +61,10 @@ export class AdminMarketDataService {
.subscribe();
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?`
title:
assetProfileCount === 1
? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
});
}
}

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -373,7 +373,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) {
for (const { code, weight } of this.assetProfile.countries) {
this.countries[code] = {
name: getCountryName({ code, locale: this.data.locale }),
name: getCountryName({ code }),
value: weight
};
}

3
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -271,8 +271,7 @@
[locale]="data.locale"
[value]="
getCountryName({
code: assetProfile?.countries[0].code,
locale: data.locale
code: assetProfile?.countries[0].code
})
"
>Country</gf-value

4
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -5,7 +5,7 @@ import {
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
getDateFnsLocale,
@ -317,7 +317,7 @@ export class GfAdminUsersComponent implements OnInit {
currentUserId: this.user?.id,
deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale ?? locale,
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE,
userId: aUserId
} satisfies UserDetailDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '60vh',

6
apps/client/src/app/components/admin-users/admin-users.html

@ -16,7 +16,7 @@
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<div class="d-flex align-items-center">
<div class="align-items-center d-flex text-nowrap">
<span
class="d-none d-sm-inline-block text-monospace"
[class.text-line-through]="element.role === 'INACTIVE'"
@ -77,7 +77,7 @@
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell
>
{{ formatDistanceToNow(element.createdAt) }}
@ -183,7 +183,7 @@
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell
>
{{ formatDistanceToNow(element.lastActivity) }}

2
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -44,7 +44,6 @@ import {
type TooltipOptions
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -86,7 +85,6 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
annotationPlugin,
LinearScale,
LineController,
LineElement,

213
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -16,6 +16,7 @@ import {
EnhancedSymbolProfile,
Filter,
LineChartItem,
NullableLineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -39,11 +40,16 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
Inject,
OnInit
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import {
@ -71,6 +77,7 @@ import {
swapVerticalOutline,
walletOutline
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators';
@ -106,75 +113,78 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number;
public accounts: Account[];
public assetClass: string;
public assetSubClass: string;
public averagePrice: number;
public averagePricePrecision = 2;
public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = $localize`Average Unit Price`;
public countries: {
protected accounts: Account[];
protected activitiesCount: number;
protected assetClass: string;
protected assetSubClass: string;
protected averagePrice: number;
protected averagePricePrecision = 2;
protected benchmarkDataItems: NullableLineChartItem[];
protected readonly benchmarkLabel = $localize`Average Unit Price`;
protected countries: {
[code: string]: { name: string; value: number };
};
public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>;
public dateOfFirstActivity: string;
public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public getCountryName = getCountryName;
public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[];
public holdingForm: FormGroup;
public investmentInBaseCurrencyWithCurrencyEffect: number;
public investmentInBaseCurrencyWithCurrencyEffectPrecision = 2;
public isUUID = isUUID;
public marketDataItems: MarketData[] = [];
public marketPrice: number;
public marketPriceMax: number;
public marketPriceMaxPrecision = 2;
public marketPriceMin: number;
public marketPriceMinPrecision = 2;
public marketPricePrecision = 2;
public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public pageIndex = 0;
public pageSize = DEFAULT_PAGE_SIZE;
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
public routerLinkAdminControlMarketData =
protected dataProviderInfo: DataProviderInfo;
protected dataSource: MatTableDataSource<Activity>;
protected dateOfFirstActivity: Date;
protected dividendInBaseCurrency: number;
protected dividendInBaseCurrencyPrecision = 2;
protected dividendYieldPercentWithCurrencyEffect: number;
protected feeInBaseCurrency: number;
protected readonly getCountryName = getCountryName;
protected hasPermissionToCreateOwnTag: boolean;
protected hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
protected historicalDataItems: LineChartItem[];
protected holdingForm: FormGroup<{
tags: FormControl<Tag[] | null>;
}>;
protected investmentInBaseCurrencyWithCurrencyEffect: number;
protected investmentInBaseCurrencyWithCurrencyEffectPrecision = 2;
protected readonly isUUID = isUUID;
protected marketDataItems: MarketData[] = [];
protected marketPrice: number;
protected marketPriceMax: number;
protected marketPriceMaxPrecision = 2;
protected marketPriceMin: number;
protected marketPriceMinPrecision = 2;
protected marketPricePrecision = 2;
protected netPerformancePercentWithCurrencyEffect: number;
protected netPerformancePercentWithCurrencyEffectPrecision = 2;
protected netPerformanceWithCurrencyEffect: number;
protected netPerformanceWithCurrencyEffectPrecision = 2;
protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected quantity: number;
protected quantityPrecision = 2;
protected reportDataGlitchMail: string;
protected readonly routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
public sectors: {
protected sectors: {
[name: string]: { name: string; value: number };
};
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public tagsAvailable: Tag[];
public translate = translate;
public user: User;
public value: number;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private router: Router,
private userService: UserService
) {
protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc';
protected SymbolProfile: EnhancedSymbolProfile;
protected tagsAvailable: Tag[];
protected readonly translate = translate;
protected user: User;
protected value: number;
protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
protected readonly dialogRef = inject(
MatDialogRef<GfHoldingDetailDialogComponent>
);
private tags: Tag[];
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
addIcons({
arrowDownCircleOutline,
createOutline,
@ -190,12 +200,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
const filters = this.getActivityFilters();
this.holdingForm = this.formBuilder.group({
tags: [] as string[]
tags: new FormControl<Tag[]>([])
});
this.holdingForm
.get('tags')
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
this.holdingForm.controls.tags.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
@ -268,8 +277,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
marketPrice,
marketPriceMax,
marketPriceMin,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
quantity,
@ -290,7 +297,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dateOfFirstActivity = dateOfFirstActivity;
if (dateOfFirstActivity) {
this.dateOfFirstActivity = dateOfFirstActivity;
}
this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
@ -318,12 +329,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({
date,
value: averagePrice
value: isNumber(averagePrice) ? averagePrice : null
});
return {
date,
value: marketPrice
value: marketPrice ?? 0
};
}
);
@ -365,17 +376,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.marketPricePrecision = 0;
}
this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect;
@ -438,10 +438,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.countries?.length > 0) {
for (const country of SymbolProfile.countries) {
this.countries[country.code] = {
name: getCountryName({
code: country.code,
locale: this.data.locale
}),
name: getCountryName({ code: country.code }),
value: country.weight
};
}
@ -456,16 +453,16 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
if (isToday(parseISO(this.dateOfFirstActivity))) {
if (isToday(this.dateOfFirstActivity)) {
// Add average price
this.historicalDataItems.push({
date: this.dateOfFirstActivity,
date: this.dateOfFirstActivity.toISOString(),
value: this.averagePrice
});
// Add benchmark 1
this.benchmarkDataItems.push({
date: this.dateOfFirstActivity,
date: this.dateOfFirstActivity.toISOString(),
value: averagePrice
});
@ -496,7 +493,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.benchmarkDataItems[0]?.value === undefined &&
isSameMonth(parseISO(this.dateOfFirstActivity), new Date())
isSameMonth(this.dateOfFirstActivity, new Date())
) {
this.benchmarkDataItems[0].value = this.averagePrice;
}
@ -526,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.hasPermissionToCreateOwnTag =
hasPermission(this.user.permissions, permissions.createOwnTag) &&
this.user?.settings?.isExperimentalFeatures;
(this.user?.settings?.isExperimentalFeatures ?? false);
this.tagsAvailable =
this.user?.tags?.map((tag) => {
@ -541,13 +538,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
});
}
public onChangePage(page: PageEvent) {
protected onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.fetchActivities();
}
public onCloneActivity(aActivity: Activity) {
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
@ -558,22 +555,22 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.dialogRef.close();
}
public onClose() {
protected onClose() {
this.dialogRef.close();
}
public onCloseHolding() {
protected onCloseHolding() {
const today = new Date();
const activity: CreateOrderDto = {
accountId: this.accounts.length === 1 ? this.accounts[0].id : null,
comment: null,
currency: this.SymbolProfile.currency,
dataSource: this.SymbolProfile.dataSource,
accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined,
comment: undefined,
currency: this.SymbolProfile?.currency ?? '',
dataSource: this.SymbolProfile?.dataSource,
date: today.toISOString(),
fee: 0,
quantity: this.quantity,
symbol: this.SymbolProfile.symbol,
symbol: this.SymbolProfile?.symbol ?? '',
tags: this.tags.map(({ id }) => {
return id;
}),
@ -593,7 +590,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
});
}
public onExport() {
protected onExport() {
const activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
@ -613,13 +610,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
});
}
public onMarketDataChanged(withRefresh = false) {
protected onMarketDataChanged(withRefresh = false) {
if (withRefresh) {
this.fetchMarketData();
}
}
public onUpdateActivity(aActivity: Activity) {
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{

3
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -274,8 +274,7 @@
[locale]="data.locale"
[value]="
getCountryName({
code: SymbolProfile.countries[0].code,
locale: data.locale
code: SymbolProfile.countries[0].code
})
"
>Country</gf-value

4
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,6 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
Benchmark,
@ -149,7 +149,7 @@ export class GfHomeWatchlistComponent implements OnInit {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? defaultLocale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});

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

@ -40,9 +40,7 @@ import {
type TooltipOptions
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin, {
type AnnotationOptions
} from 'chartjs-plugin-annotation';
import { type AnnotationOptions } from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -74,7 +72,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
annotationPlugin,
BarController,
BarElement,
LinearScale,

30
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,8 +1,7 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
@ -14,22 +13,37 @@ import { MatSliderModule } from '@angular/material/slider';
import { RuleSettingsDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
GfValueComponent,
MatButtonModule,
MatDialogModule,
MatSliderModule
MatSliderModule,
ReactiveFormsModule
],
selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html'
})
export class GfRuleSettingsDialogComponent {
public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
public settingsForm: FormGroup;
public constructor(
@Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) {}
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>,
private formBuilder: FormBuilder
) {
this.settingsForm = this.formBuilder.group({
thresholdMax: [this.data.settings.thresholdMax],
thresholdMin: [this.data.settings.thresholdMin]
});
}
public onSubmit() {
this.dialogRef.close({
...this.data.settings,
thresholdMax: this.settingsForm.get('thresholdMax')?.value,
thresholdMin: this.settingsForm.get('thresholdMin')?.value
});
}
}

262
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,132 +1,142 @@
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
@if (
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
) {
<div class="w-100">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold range</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMin"
/>
<span class="mx-1">-</span>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
<form
class="d-flex flex-column h-100"
[formGroup]="settingsForm"
(ngSubmit)="onSubmit()"
>
<div class="py-3" mat-dialog-content>
@if (
data.rule.configuration.thresholdMin &&
data.rule.configuration.thresholdMax
) {
<div class="w-100">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold range</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="settingsForm.get('thresholdMin').value"
/>
<span class="mx-1">-</span>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="settingsForm.get('thresholdMax').value"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input formControlName="thresholdMin" matSliderStartThumb />
<input formControlName="thresholdMax" matSliderEndThumb />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
</div>
} @else {
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Min</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMin"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMin"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
} @else {
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Min</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="settingsForm.get('thresholdMin').value"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMin"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input formControlName="thresholdMin" matSliderThumb />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
</div>
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Max</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMax"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax">
<h6 class="d-flex mb-0">
<ng-container i18n>Threshold Max</ng-container>:
<gf-value
class="ml-1"
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="settingsForm.get('thresholdMax').value"
/>
</h6>
<div class="align-items-center d-flex w-100">
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.min"
/>
<mat-slider
class="flex-grow-1"
name="thresholdMax"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input formControlName="thresholdMax" matSliderThumb />
</mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div>
</div>
}
</div>
}
</div>
<div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button>
<button
color="primary"
mat-flat-button
(click)="dialogRef.close(data.settings)"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
<div align="end" mat-dialog-actions>
<button mat-button type="button" (click)="dialogRef.close()">
<ng-container i18n>Close</ng-container>
</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!settingsForm.dirty"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

4
apps/client/src/app/components/rules/rules.component.html

@ -3,9 +3,7 @@
<div class="col">
@if (isLoading) {
<gf-rule [isLoading]="true" />
}
@if (rules !== null && rules !== undefined) {
} @else if (rules) {
@for (rule of rules; track rule.key) {
<gf-rule
[categoryName]="categoryName"

2
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -94,7 +94,7 @@
<ng-container i18n>or start a discussion at</ng-container>
<ng-container>&nbsp;</ng-container>
<a
href="https://github.com/ghostfolio/ghostfolio"
href="https://github.com/ghostfolio/ghostfolio/discussions"
i18n-title
title="Find Ghostfolio on GitHub"
>GitHub</a

63
apps/client/src/app/pages/admin/admin-page.component.ts

@ -1,10 +1,19 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE
} from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import {
GfPageTabsComponent,
TabConfiguration
} from '@ghostfolio/ui/page-tabs';
import { Component, OnInit } from '@angular/core';
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons';
import {
flashOutline,
@ -21,10 +30,23 @@ import {
styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html'
})
export class AdminPageComponent implements OnInit {
export class AdminPageComponent {
public tabs: TabConfiguration[] = [];
private user: User;
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() {
this.userService.stateChanged
.pipe(takeUntilDestroyed())
.subscribe((state) => {
this.user = state?.user;
this.initializeTabs();
});
addIcons({
flashOutline,
peopleOutline,
@ -34,7 +56,12 @@ export class AdminPageComponent implements OnInit {
});
}
public ngOnInit() {
private initializeTabs() {
const hasPermissionToAccessBullBoard = hasPermission(
this.user?.permissions,
permissions.accessAdminControlBullBoard
);
this.tabs = [
{
iconName: 'reader-outline',
@ -51,11 +78,19 @@ export class AdminPageComponent implements OnInit {
label: internalRoutes.adminControl.subRoutes.marketData.title,
routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink
},
{
iconName: 'flash-outline',
label: internalRoutes.adminControl.subRoutes.jobs.title,
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink
},
hasPermissionToAccessBullBoard
? {
iconName: 'flash-outline',
label: $localize`Job Queue`,
onClick: () => {
this.onOpenBullBoard();
}
}
: {
iconName: 'flash-outline',
label: internalRoutes.adminControl.subRoutes.jobs.title,
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink
},
{
iconName: 'people-outline',
label: internalRoutes.adminControl.subRoutes.users.title,
@ -63,4 +98,16 @@ export class AdminPageComponent implements OnInit {
}
];
}
private onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
}

2
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -193,7 +193,7 @@
}
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content

2
apps/client/src/app/pages/faq/saas/saas-page.html

@ -186,7 +186,7 @@
}
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content

2
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -294,7 +294,7 @@
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.</mat-card-content

5
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -1,6 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
@ -120,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user.settings.locale ?? defaultLocale;
this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale);

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -2,8 +2,6 @@
display: block;
.mat-mdc-dialog-content {
max-height: unset;
a {
color: rgba(var(--palette-primary-500), 1);
}

11
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -185,10 +185,10 @@ export class GfAllocationsPageComponent implements OnInit {
this.initialize();
}
public onAccountChartClicked({ symbol }: AssetProfileIdentifier) {
if (symbol && symbol !== UNKNOWN_KEY) {
public onAccountChartClicked({ accountId }: { accountId: string }) {
if (accountId && accountId !== UNKNOWN_KEY) {
this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true }
queryParams: { accountId, accountDetailDialog: true }
});
}
}
@ -399,10 +399,7 @@ export class GfAllocationsPageComponent implements OnInit {
: position.valueInPercentage);
} else {
this.countries[code] = {
name: getCountryName({
code,
locale: this.user?.settings?.locale
}),
name: getCountryName({ code }),
value:
weight *
(isNumber(position.valueInBaseCurrency)

4
apps/client/src/app/pages/portfolio/fire/fire-page.scss

@ -29,5 +29,9 @@
@include select-arrow(variables.$light-primary-text);
color: rgb(var(--light-primary-text));
option {
color: rgb(var(--dark-primary-text));
}
}
}

14
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -68,20 +68,6 @@ export class GfPricingPageComponent implements OnInit {
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
);
protected readonly referralBrokers = [
'Alpian',
'DEGIRO',
'finpension',
'frankly',
'Interactive Brokers',
'Mintos',
'Monefit SmartSaver',
'Revolut',
'Swissquote',
'VIAC',
'Zak'
] as const;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkRegister = publicRoutes.register.routerLink;
protected user: User;

10
apps/client/src/app/pages/pricing/pricing-page.html

@ -305,23 +305,23 @@
</div>
</div>
</div>
@if (user?.subscription?.type === 'Basic') {
@if (user?.referralPartners?.length) {
<div class="row">
<div class="col mt-3">
<p>
<ng-container i18n>If you plan to open an account at</ng-container>
<ng-container>&nbsp;</ng-container>
@for (
broker of referralBrokers;
track broker;
partner of user.referralPartners;
track partner.name;
let i = $index;
let last = $last
) {
<i>{{ broker }}</i>
<i>{{ partner.name }}</i>
@if (last) {
<span>, </span>
} @else {
@if (i === referralBrokers.length - 2) {
@if (i === user.referralPartners.length - 2) {
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>

8
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -33,7 +33,6 @@ export class GfProductPageComponent implements OnInit {
) {}
public ngOnInit() {
const locale = document.documentElement.lang;
const { subscriptionOffer } = this.dataService.fetchInfo();
this.price = subscriptionOffer?.price;
@ -57,7 +56,7 @@ export class GfProductPageComponent implements OnInit {
'Türkçe'
],
name: 'Ghostfolio',
origin: getCountryName({ locale, code: 'CH' }),
origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
@ -70,10 +69,7 @@ export class GfProductPageComponent implements OnInit {
};
if (this.product2.origin) {
this.product2.origin = getCountryName({
locale,
code: this.product2.origin
});
this.product2.origin = getCountryName({ code: this.product2.origin });
}
if (this.product2.regions) {

2
apps/client/src/locales/messages.de.xlf

@ -2143,7 +2143,7 @@
</trans-unit>
<trans-unit id="7500665368930738879" datatype="html">
<source>or start a discussion at</source>
<target state="translated">oder beginne eine Diskussion unter</target>
<target state="translated">oder beginne eine Diskussion bei</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">94</context>

22
apps/client/src/locales/messages.es.xlf

@ -872,7 +872,7 @@
</trans-unit>
<trans-unit id="273949409065292025" datatype="html">
<source>Energy</source>
<target state="new">Energy</target>
<target state="translated">Energía</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">90</context>
@ -1208,7 +1208,7 @@
</trans-unit>
<trans-unit id="4432485267082955422" datatype="html">
<source>Consumer Defensive</source>
<target state="new">Consumer Defensive</target>
<target state="translated">Consumo Básico</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">89</context>
@ -1276,7 +1276,7 @@
</trans-unit>
<trans-unit id="3848479214898655176" datatype="html">
<source>Utilities</source>
<target state="new">Utilities</target>
<target state="translated">Servicios Públicos</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">97</context>
@ -2004,7 +2004,7 @@
</trans-unit>
<trans-unit id="2691624743109891676" datatype="html">
<source>Consumer Cyclical</source>
<target state="new">Consumer Cyclical</target>
<target state="translated">Consumo Discrecional</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">88</context>
@ -2948,7 +2948,7 @@
</trans-unit>
<trans-unit id="8432027249343784512" datatype="html">
<source>Real Estate</source>
<target state="translated">Propiedad inmobiliaria</target>
<target state="translated">Inmobiliario</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">52</context>
@ -3108,7 +3108,7 @@
</trans-unit>
<trans-unit id="8340410730497371637" datatype="html">
<source>Communication Services</source>
<target state="new">Communication Services</target>
<target state="translated">Servicios de Comunicación</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">87</context>
@ -4316,7 +4316,7 @@
</trans-unit>
<trans-unit id="5765523585863707029" datatype="html">
<source>Technology</source>
<target state="new">Technology</target>
<target state="translated">Tecnología</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">96</context>
@ -4964,7 +4964,7 @@
</trans-unit>
<trans-unit id="6945194064393203435" datatype="html">
<source>Basic Materials</source>
<target state="new">Basic Materials</target>
<target state="translated">Materiales Básicos</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">86</context>
@ -5690,7 +5690,7 @@
</trans-unit>
<trans-unit id="4581276194280068721" datatype="html">
<source>Industrials</source>
<target state="new">Industrials</target>
<target state="translated">Industriales</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">93</context>
@ -5806,7 +5806,7 @@
</trans-unit>
<trans-unit id="7194017842579795231" datatype="html">
<source>Healthcare</source>
<target state="new">Healthcare</target>
<target state="translated">Salud</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">92</context>
@ -6951,7 +6951,7 @@
</trans-unit>
<trans-unit id="7521745345796915891" datatype="html">
<source>Financial Services</source>
<target state="new">Financial Services</target>
<target state="translated">Servicios Financieros</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">91</context>

3
apps/client/src/main.ts

@ -1,5 +1,6 @@
import { InfoResponse } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { GfNotificationModule } from '@ghostfolio/ui/notifications';
@ -58,6 +59,8 @@ import { environment } from './environments/environment';
enableProdMode();
}
registerChartConfiguration();
await bootstrapApplication(GfAppComponent, {
providers: [
authInterceptorProviders,

8
apps/client/src/styles/theme.scss

@ -312,7 +312,10 @@ $gf-typography: (
@include mat.checkbox-overrides(
(
selected-icon-color: var(--gf-theme-primary-500)
selected-focus-icon-color: var(--gf-theme-primary-500),
selected-hover-icon-color: var(--gf-theme-primary-500),
selected-icon-color: var(--gf-theme-primary-500),
selected-pressed-icon-color: var(--gf-theme-primary-500)
)
);
@ -392,8 +395,11 @@ $gf-typography: (
@include mat.tabs-overrides(
(
active-focus-indicator-color: var(--gf-theme-primary-500),
active-focus-label-text-color: var(--gf-theme-primary-500),
active-hover-indicator-color: var(--gf-theme-primary-500),
active-hover-label-text-color: var(--gf-theme-primary-500),
active-indicator-color: var(--gf-theme-primary-500),
active-label-text-color: var(--gf-theme-primary-500),
active-ripple-color: var(--gf-theme-primary-500),
inactive-ripple-color: var(--gf-theme-primary-500)

4
libs/common/src/lib/config.ts

@ -13,8 +13,6 @@ export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPr
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;
export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`;
export const locale = 'en-US';
export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = {
r: 54,
@ -85,6 +83,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_DATE_RANGE: DateRange = 'max';
export const DEFAULT_HOST = '0.0.0.0';
export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
@ -253,6 +252,7 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';

20
libs/common/src/lib/helper.ts

@ -36,12 +36,12 @@ import { get, isNil, isString } from 'lodash';
import {
DEFAULT_CURRENCY,
DEFAULT_LOCALE,
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix,
locale
ghostfolioScraperApiSymbolPrefix
} from './config';
import {
AssetProfileItem,
@ -258,15 +258,13 @@ export function getCurrencyFromSymbol(aSymbol = '') {
return aSymbol.replace(DEFAULT_CURRENCY, '');
}
export function getCountryName({
code,
locale = getLocale()
}: {
code: string;
locale?: string;
}): string {
export function getCountryName({ code }: { code: string }): string {
try {
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code;
return (
new Intl.DisplayNames([document.documentElement.lang || DEFAULT_LOCALE], {
type: 'region'
}).of(code) ?? code
);
} catch {
return code;
}
@ -340,7 +338,7 @@ export function getEmojiFlag(aCountryCode: string) {
}
export function getLocale() {
return navigator.language ?? locale;
return navigator.language ?? DEFAULT_LOCALE;
}
export function getLowercase(object: object, path: string) {

8
libs/common/src/lib/interfaces/index.ts

@ -22,7 +22,10 @@ import type { HoldingWithParents } from './holding-with-parents.interface';
import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface';
import type {
LineChartItem,
NullableLineChartItem
} from './line-chart-item.interface';
import type { LookupItem } from './lookup-item.interface';
import type { MarketData } from './market-data.interface';
import type { PortfolioChart } from './portfolio-chart.interface';
@ -32,6 +35,7 @@ import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Product } from './product';
import type { ReferralPartner } from './referral-partner.interface';
import type { AccessTokenResponse } from './responses/access-token-response.interface';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { AccountResponse } from './responses/account-response.interface';
@ -156,6 +160,7 @@ export {
MarketData,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse,
NullableLineChartItem,
OAuthResponse,
PlatformsResponse,
PortfolioChart,
@ -175,6 +180,7 @@ export {
PublicKeyCredentialRequestOptionsJSON,
PublicPortfolioResponse,
QuotesResponse,
ReferralPartner,
ResponseError,
RuleSettings,
ScraperConfiguration,

6
libs/common/src/lib/interfaces/line-chart-item.interface.ts

@ -1,4 +1,6 @@
export interface LineChartItem {
export interface LineChartItem<T = number> {
date: string;
value: number;
value: T;
}
export type NullableLineChartItem = LineChartItem<number | null>;

3
libs/common/src/lib/interfaces/referral-partner.interface.ts

@ -0,0 +1,3 @@
export interface ReferralPartner {
name: string;
}

16
libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts

@ -9,6 +9,19 @@ import { Tag } from '@prisma/client';
export interface PortfolioHoldingResponse {
activitiesCount: number;
assetProfile: Pick<
EnhancedSymbolProfile,
| 'assetClass'
| 'assetSubClass'
| 'countries'
| 'currency'
| 'dataSource'
| 'isin'
| 'name'
| 'sectors'
| 'symbol'
| 'userId'
>;
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dateOfFirstActivity: string;
@ -31,7 +44,10 @@ export interface PortfolioHoldingResponse {
netPerformanceWithCurrencyEffect: number;
performances: Benchmark['performances'];
quantity: number;
/* @deprecated */
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
value: number;
}

2
libs/common/src/lib/interfaces/user.interface.ts

@ -3,6 +3,7 @@ import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Access, Tag } from '@prisma/client';
import { ReferralPartner } from './referral-partner.interface';
import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface';
import { UserSettings } from './user-settings.interface';
@ -15,6 +16,7 @@ export interface User {
dateOfFirstActivity: Date;
id: string;
permissions: string[];
referralPartners?: ReferralPartner[];
settings: UserSettings;
systemMessage?: SystemMessage;
subscription: {

79
libs/common/src/lib/personal-finance-tools.ts

@ -1,6 +1,17 @@
import { Product } from '@ghostfolio/common/interfaces';
export const personalFinanceTools: Product[] = [
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'acemoney',
name: 'AceMoney',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$44.95',
slogan: 'Lifetime Personal Finance Control, One Single Payment.',
url: 'https://www.mechcad.net'
},
{
founded: 2023,
hasSelfHostingAbility: false,
@ -361,6 +372,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Your financial superpower',
url: 'https://www.etops.com'
},
{
founded: 2015,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'everydollar',
name: 'EveryDollar',
origin: 'US',
slogan: 'Plan, track, find more margin',
url: 'https://www.ramseysolutions.com/money/everydollar'
},
{
founded: 2020,
hasFreePlan: true,
@ -497,6 +518,17 @@ export const personalFinanceTools: Product[] = [
slogan: 'Take control over your investments',
url: 'https://www.folishare.com'
},
{
founded: 1993,
hasSelfHostingAbility: true,
key: 'fund-manager',
name: 'Fund Manager',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$99',
slogan: 'Powerful portfolio management software',
url: 'https://www.fundmanagersoftware.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
@ -528,6 +560,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Portfolio Tracker, Analysis & Community',
url: 'https://www.getquin.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'goodbudget',
name: 'Goodbudget',
origin: 'US',
slogan: 'Budget with a why',
url: 'https://goodbudget.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
@ -665,6 +706,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Sustainability insights for wealth managers',
url: 'https://leafs.ch'
},
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'lunch-money',
name: 'Lunch Money',
origin: 'CA',
slogan: 'Delightfully simple personal finance and budgeting',
url: 'https://lunchmoney.app'
},
{
founded: 2018,
hasFreePlan: false,
@ -725,6 +776,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'The smartest way to track your crypto',
url: 'https://www.merlincrypto.com'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'mezzi',
name: 'Mezzi',
origin: 'US',
pricingPerYear: '$48',
slogan: 'Self-manage your wealth. Get fiduciary advice.',
url: 'https://www.mezzi.com'
},
{
founded: 1991,
hasSelfHostingAbility: true,
@ -776,6 +837,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Have total control of your financial life',
url: 'https://www.moneyspire.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'moneywell',
name: 'MoneyWell',
origin: 'US',
slogan: 'Personal Budgeting Software for Mac and iOS',
url: 'https://moneywell.app'
},
{
key: 'moneywiz',
name: 'MoneyWiz',
@ -1109,6 +1179,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Stock Portfolio Tracker',
url: 'https://simpleportfolio.app'
},
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'simplifi',
name: 'Simplifi by Quicken',
origin: 'US',
url: 'https://www.quicken.com/products/simplifi'
},
{
founded: 2014,
hasFreePlan: true,

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -49,7 +49,7 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.platform?.url) {
<gf-entity-logo
class="d-inline d-sm-none mr-1"

12
libs/ui/src/lib/activities-table/activities-table.component.html

@ -161,13 +161,11 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="align-items-center d-flex line-height-1">
<div>
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
@if (element.isDraft) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
}
</div>
<div class="align-items-center d-flex line-height-1 text-nowrap">
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
@if (element.isDraft) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
}
</div>
@if (
element.SymbolProfile?.dataSource !== 'MANUAL' &&

7
libs/ui/src/lib/chart/chart.registry.ts

@ -1,6 +1,7 @@
import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper';
import { Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js';
import { Chart, Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
interface VerticalHoverLinePluginOptions {
color?: string;
@ -23,6 +24,10 @@ export function registerChartConfiguration() {
return;
}
// Register the annotation plugin early so that every chart is initialized
// with its state and does not crash on interaction
Chart.register(annotationPlugin);
Tooltip.positioners.top = function (_elements, eventPosition) {
return getTooltipPositionerMapTop(this.chart, eventPosition);
};

4
libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts

@ -1,4 +1,4 @@
import { locale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -47,7 +47,7 @@ export const Simple: Story = {
annualInterestRate: 5,
currency: 'USD',
fireWealth: 50000,
locale: locale,
locale: DEFAULT_LOCALE,
savingsRate: 1000
}
};

6
libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts

@ -68,7 +68,7 @@ export class GfHistoricalMarketDataEditorComponent
@Input() currency: string;
@Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string;
@Input() dateOfFirstActivity: Date;
@Input() symbol: string;
@Input() user: User;
@ -124,7 +124,7 @@ export class GfHistoricalMarketDataEditorComponent
public ngOnChanges() {
if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity);
let date = this.dateOfFirstActivity;
const missingMarketData: { date: Date; marketPrice?: number }[] = [];
@ -174,7 +174,7 @@ export class GfHistoricalMarketDataEditorComponent
const dates = Object.keys(this.marketDataByMonth).sort();
const startDateString = first(dates);
const startDate = min([
parseISO(this.dateOfFirstActivity),
this.dateOfFirstActivity,
...(startDateString ? [parseISO(startDateString)] : [])
]);
const endDateString = last(dates);

9
libs/ui/src/lib/page-tabs/interfaces/interfaces.ts

@ -1,6 +1,11 @@
export interface TabConfiguration {
interface BaseTabConfiguration {
iconName: string;
label: string;
routerLink: string[];
showCondition?: boolean;
}
export type TabConfiguration = BaseTabConfiguration &
(
| { onClick: () => void; routerLink?: never }
| { onClick?: never; routerLink: string[] }
);

49
libs/ui/src/lib/page-tabs/page-tabs.component.html

@ -10,21 +10,40 @@
>
@for (tab of tabs(); track tab) {
@if (tab.showCondition !== false) {
<a
#rla="routerLinkActive"
class="no-min-width px-3"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.routerLink"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large' : 'small'"
/>
<div class="d-none d-sm-block ml-2" [innerHTML]="tab.label"></div>
</a>
@if (tab.onClick) {
<button
class="no-min-width overflow-hidden px-2"
mat-tab-link
type="button"
(click)="tab.onClick()"
>
<ng-container
*ngTemplateOutlet="tabContent; context: { $implicit: tab }"
/>
</button>
} @else {
<a
#rla="routerLinkActive"
class="no-min-width overflow-hidden px-2"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.routerLink"
[routerLinkActiveOptions]="{ exact: true }"
>
<ng-container
*ngTemplateOutlet="tabContent; context: { $implicit: tab }"
/>
</a>
}
}
}
</nav>
<ng-template #tabContent let-tab>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large' : 'small'"
/>
<div class="d-none d-sm-block ml-1" [innerHTML]="tab.label"></div>
</ng-template>

43
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -37,7 +37,12 @@
@include mat.tabs-overrides(
(
container-height: 2rem
active-focus-label-text-color: rgba(var(--palette-foreground-base), 1),
active-hover-label-text-color: rgba(var(--palette-foreground-base), 1),
active-label-text-color: rgba(var(--palette-foreground-base), 1),
active-ripple-color: rgba(var(--palette-foreground-base), 1),
container-height: 2rem,
inactive-ripple-color: rgba(var(--palette-foreground-base), 1)
)
);
@ -51,7 +56,15 @@
flex-direction: column;
.mat-mdc-tab-link {
border-radius: 0.25rem;
font-weight: 400;
justify-content: flex-start;
margin: 0 0.5rem 0.1rem 0.5rem;
&.mdc-tab--active {
background-color: rgba(var(--palette-foreground-base), 0.05);
font-weight: 500;
}
}
}
}
@ -61,8 +74,32 @@
:host-context(.theme-dark) {
@media (min-width: 576px) {
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
@include mat.tabs-overrides(
(
active-focus-label-text-color: rgba(
var(--palette-foreground-base-dark),
1
),
active-hover-label-text-color: rgba(
var(--palette-foreground-base-dark),
1
),
active-label-text-color: rgba(var(--palette-foreground-base-dark), 1),
active-ripple-color: rgba(var(--palette-foreground-base-dark), 1),
inactive-ripple-color: rgba(var(--palette-foreground-base-dark), 1)
)
);
::ng-deep {
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
.mat-mdc-tab-link {
&.mdc-tab--active {
background-color: rgba(var(--palette-foreground-base-dark), 0.05);
}
}
}
}
}
}

3
libs/ui/src/lib/page-tabs/page-tabs.component.ts

@ -1,3 +1,4 @@
import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -13,7 +14,7 @@ import { TabConfiguration } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon, MatTabsModule, RouterModule],
imports: [IonIcon, MatTabsModule, NgTemplateOutlet, RouterModule],
selector: 'gf-page-tabs',
styleUrls: ['./page-tabs.component.scss'],
templateUrl: './page-tabs.component.html'

5
libs/ui/src/lib/portfolio-proportion-chart/interfaces/interfaces.ts

@ -0,0 +1,5 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export type PortfolioProportionChartClickEvent =
| AssetProfileIdentifier
| { accountId: string };

18
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -1,10 +1,7 @@
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
import {
@ -36,6 +33,8 @@ import Color from 'color';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color';
import { PortfolioProportionChartClickEvent } from './interfaces/interfaces';
const {
blue,
cyan,
@ -80,7 +79,8 @@ export class GfPortfolioProportionChartComponent
public chart: Chart<'doughnut'>;
public isLoading = true;
protected readonly proportionChartClicked = output<AssetProfileIdentifier>();
protected readonly proportionChartClicked =
output<PortfolioProportionChartClickEvent>();
private readonly OTHER_KEY = 'OTHER';
@ -355,11 +355,11 @@ export class GfPortfolioProportionChartComponent
const dataIndex = activeElements[0].index;
const symbol = chart.data.labels?.[dataIndex] as string;
const dataSource = this.data[symbol].dataSource;
const dataSource = this.data[symbol]?.dataSource;
if (dataSource) {
this.proportionChartClicked.emit({ dataSource, symbol });
}
this.proportionChartClicked.emit(
dataSource ? { dataSource, symbol } : { accountId: symbol }
);
} catch {}
},
onHover: (event, chartElement) => {

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

@ -445,10 +445,27 @@ export class DataService {
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.http.get<PortfolioHoldingResponse>(
`/api/v1/portfolio/holding/${dataSource}/${symbol}`
);
}): Observable<
Omit<PortfolioHoldingResponse, 'dateOfFirstActivity'> & {
dateOfFirstActivity: Date | undefined;
}
> {
return this.http
.get<PortfolioHoldingResponse>(
`/api/v1/portfolio/holding/${dataSource}/${symbol}`
)
.pipe(
map((response) => {
const dateOfFirstActivity = response.dateOfFirstActivity
? parseISO(response.dateOfFirstActivity)
: undefined;
return {
...response,
dateOfFirstActivity
};
})
);
}
public fetchInfo(): InfoItem {

2
libs/ui/src/lib/world-map-chart/world-map-chart.component.ts

@ -95,7 +95,7 @@ export class GfWorldMapChartComponent implements OnChanges, OnDestroy {
this.svgMapElement.options.countryNames = Object.keys(
this.svgMapElement.countries
).reduce<{ [code: string]: string }>((names, code) => {
names[code] = getCountryName({ code, locale: this.locale });
names[code] = getCountryName({ code });
return names;
}, {});

68
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.10.0",
"version": "3.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.10.0",
"version": "3.12.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7",
"@bull-board/api": "7.1.5",
"@bull-board/express": "7.1.5",
"@bull-board/nestjs": "7.1.5",
"@bull-board/api": "7.2.1",
"@bull-board/express": "7.2.1",
"@bull-board/nestjs": "7.2.1",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6",
@ -63,7 +63,7 @@
"countries-and-timezones": "3.9.0",
"countries-list": "3.3.0",
"countup.js": "2.10.0",
"date-fns": "4.1.0",
"date-fns": "4.4.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.1",
@ -91,7 +91,7 @@
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "21.0.1",
"svgmap": "2.19.3",
"svgmap": "2.21.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.0",
"undici": "7.24.4",
@ -3523,33 +3523,33 @@
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bull-board/api": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz",
"integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.2.1.tgz",
"integrity": "sha512-ldRG4POJLHf6oDrbDA7AsbTKliBmV4eySlwdUAumiRDtfvtbRSdXGE4Md2uPDova1r/ck7ExEe1+pHEQAZElqw==",
"license": "MIT",
"dependencies": {
"redis-info": "^3.1.0"
},
"peerDependencies": {
"@bull-board/ui": "7.1.5"
"@bull-board/ui": "7.2.1"
}
},
"node_modules/@bull-board/express": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.1.5.tgz",
"integrity": "sha512-kp4SzhVjZlykryiQwcOhJjDhiLbBnZoAMoSgEstzqQ0raLw+jERRC6ryJ0MIQO+SO+Jv9EjjxrXCR8O2YSP/eg==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.2.1.tgz",
"integrity": "sha512-tBr/xV5letzKYPRGRkilTQZmfoCoy3mCuUo4M2dDoDKOhbrF360mK5v9/rIcSgYSyI9c7BgEgrve80LhmexNxQ==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "7.1.5",
"@bull-board/ui": "7.1.5",
"ejs": "^5.0.2",
"@bull-board/api": "7.2.1",
"@bull-board/ui": "7.2.1",
"ejs": "^6.0.1",
"express": "^5.2.1"
}
},
"node_modules/@bull-board/express/node_modules/ejs": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz",
"integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0",
"bin": {
"ejs": "bin/cli.js"
@ -3559,12 +3559,12 @@
}
},
"node_modules/@bull-board/nestjs": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.1.5.tgz",
"integrity": "sha512-1y+HkjnDaZoSCXJRsiYfBNBVx+PX3I8x3Uv+SSJuSpt2vHifMRwFbChO3XDxeWXetT1eR+yqPVq6ub5eJwNOYQ==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.2.1.tgz",
"integrity": "sha512-Uq2Z3+0ORgHJSw4TDV1kBrHdksRnK8CZdda63hrStduPnvKHPBxIZUGNfBN/vL08UqizpNkjFmNyNXiHOgf0LQ==",
"license": "MIT",
"peerDependencies": {
"@bull-board/api": "^7.1.5",
"@bull-board/api": "^7.2.1",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -3573,12 +3573,12 @@
}
},
"node_modules/@bull-board/ui": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz",
"integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==",
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.2.1.tgz",
"integrity": "sha512-O4ykrXrl2UJNHnhJrCvJxrw1ar+DlUBgyZUeZ8Ci+Ne5Wbq6rBv1gfpQH54/eu3IFbLso0S/kjc6WUGb2HPqZw==",
"license": "MIT",
"dependencies": {
"@bull-board/api": "7.1.5"
"@bull-board/api": "7.2.1"
}
},
"node_modules/@cacheable/utils": {
@ -20491,9 +20491,9 @@
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
@ -36530,9 +36530,9 @@
"license": "BSD-2-Clause"
},
"node_modules/svgmap": {
"version": "2.19.3",
"resolved": "https://registry.npmjs.org/svgmap/-/svgmap-2.19.3.tgz",
"integrity": "sha512-LjKVzKgANVMRiHqFQVz57FqqX7tVm+EskubySCWb5kvizdeDBanscPA/c4tmK/48VCvYxrR1ecBbqStcD6HYfQ==",
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/svgmap/-/svgmap-2.21.0.tgz",
"integrity": "sha512-KQNP4rkkYe3D4EgqSxX+QoRzl13oDaOt5Oqe/ClIJme/u/AaQXMU+SXgt7iL4rfqK+lRc+15dXp+y7+zQg5WiA==",
"license": "MIT",
"dependencies": {
"svg-pan-zoom": "^3.6.2"

12
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.10.0",
"version": "3.12.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7",
"@bull-board/api": "7.1.5",
"@bull-board/express": "7.1.5",
"@bull-board/nestjs": "7.1.5",
"@bull-board/api": "7.2.1",
"@bull-board/express": "7.2.1",
"@bull-board/nestjs": "7.2.1",
"@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6",
@ -107,7 +107,7 @@
"countries-and-timezones": "3.9.0",
"countries-list": "3.3.0",
"countup.js": "2.10.0",
"date-fns": "4.1.0",
"date-fns": "4.4.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.1",
@ -135,7 +135,7 @@
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "21.0.1",
"svgmap": "2.19.3",
"svgmap": "2.21.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.0",
"undici": "7.24.4",

Loading…
Cancel
Save