Browse Source

Merge branch 'ghostfolio:main' into Overview_Graph

pull/5570/head
Batwam 4 weeks ago
committed by GitHub
parent
commit
12cd62626c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      CHANGELOG.md
  2. 18
      apps/api/src/app/admin/admin.controller.ts
  3. 14
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  4. 1
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  5. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  6. 133
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  7. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  8. 471
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  9. 63
      libs/ui/src/lib/assistant/assistant.component.ts
  10. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  11. 4
      package-lock.json
  12. 2
      package.json

10
CHANGELOG.md

@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support for a date range query parameter in the data gathering endpoint
- Added a _Storybook_ story for the activities table component
## 2.206.0 - 2025-10-04
### Changed ### Changed
- Localized the number formatting in the settings dialog to customize the rule thresholds of the _X-ray_ page
- Improved the usability of the assistant by preselecting the first search result
- Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog - Improved the usability of the _Cancel_ / _Close_ buttons in the create watchlist item dialog
- Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint - Refactored the `fireWealth` from `number` type to a structured object in the summary of the portfolio details endpoint
- Refactored the _Open Startup_ (`/open`) page to standalone - Refactored the _Open Startup_ (`/open`) page to standalone
@ -18,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service - Handled an exception in the get asset profile functionality of the _Financial Modeling Prep_ service
- Added the missing `CommonModule` import in the import activities dialog
## 2.205.0 - 2025-10-01 ## 2.205.0 - 2025-10-01

18
apps/api/src/app/admin/admin.controller.ts

@ -6,6 +6,7 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -22,6 +23,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
DateRange,
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -161,9 +163,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange
): Promise<void> { ): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange, new Date());
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return; return;
} }

14
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -94,17 +94,21 @@ export class DataGatheringService {
}); });
} }
public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public async gatherSymbol({ dataSource, date, symbol }: IDataGatheringItem) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter( const dataGatheringItems = (await this.getSymbolsMax())
(dataGatheringItem) => { .filter((dataGatheringItem) => {
return ( return (
dataGatheringItem.dataSource === dataSource && dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol dataGatheringItem.symbol === symbol
); );
} })
); .map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({ await this.gatherSymbols({
dataGatheringItems, dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH

1
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -5,6 +5,7 @@ import {
export interface IRuleSettingsDialogParams { export interface IRuleSettingsDialogParams {
categoryName: string; categoryName: string;
locale: string;
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
} }

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

@ -1,4 +1,5 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
@ -17,6 +18,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSliderModule MatSliderModule

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

@ -5,28 +5,30 @@
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
) { ) {
<div class="w-100"> <div class="w-100">
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold range</ng-container>: <ng-container i18n>Threshold range</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMin | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMin }} [locale]="data.locale"
} [precision]="2"
- [value]="data.settings.thresholdMin"
@if (data.rule.configuration.threshold.unit === '%') { />
{{ data.settings.thresholdMax | percent: '1.2-2' }} <span class="mx-1">-</span>
} @else { <gf-value
{{ data.settings.thresholdMax }} [isPercent]="data.rule.configuration.threshold.unit === '%'"
} [locale]="data.locale"
[precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
[max]="data.rule.configuration.threshold.max" [max]="data.rule.configuration.threshold.max"
@ -36,13 +38,12 @@
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> <input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> <input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
} @else { } @else {
@ -50,22 +51,23 @@
class="w-100" class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
> >
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold Min</ng-container>: <ng-container i18n>Threshold Min</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMin | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMin }} [locale]="data.locale"
} [precision]="2"
[value]="data.settings.thresholdMin"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
name="thresholdMin" name="thresholdMin"
@ -75,35 +77,35 @@
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> <input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
<div <div
class="w-100" class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
> >
<h6 class="mb-0"> <h6 class="d-flex mb-0">
<ng-container i18n>Threshold Max</ng-container>: <ng-container i18n>Threshold Max</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
{{ data.settings.thresholdMax | percent: '1.2-2' }} class="ml-1"
} @else { [isPercent]="data.rule.configuration.threshold.unit === '%'"
{{ data.settings.thresholdMax }} [locale]="data.locale"
} [precision]="2"
[value]="data.settings.thresholdMax"
/>
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.min | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.min"
<label>{{ data.rule.configuration.threshold.min }}</label> />
}
<mat-slider <mat-slider
class="flex-grow-1" class="flex-grow-1"
name="thresholdMax" name="thresholdMax"
@ -113,13 +115,12 @@
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> <input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider> </mat-slider>
@if (data.rule.configuration.threshold.unit === '%') { <gf-value
<label>{{ [isPercent]="data.rule.configuration.threshold.unit === '%'"
data.rule.configuration.threshold.max | percent: '1.2-2' [locale]="data.locale"
}}</label> [precision]="2"
} @else { [value]="data.rule.configuration.threshold.max"
<label>{{ data.rule.configuration.threshold.max }}</label> />
}
</div> </div>
</div> </div>
} }

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

@ -15,6 +15,7 @@ import {
StepperOrientation, StepperOrientation,
StepperSelectionEvent StepperSelectionEvent
} from '@angular/cdk/stepper'; } from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -59,6 +60,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
CommonModule,
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,

471
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -0,0 +1,471 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NotificationService } from 'apps/client/src/app/core/notification/notification.service';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
import { GfEntityLogoComponent } from '../entity-logo';
import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component';
import { GfValueComponent } from '../value';
import { GfActivitiesTableComponent } from './activities-table.component';
const activities: Activity[] = [
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2025-04-09T13:47:33.133Z'),
currency: 'USD',
date: new Date('2025-04-09T13:45:45.504Z'),
fee: 1,
id: 'a76968ff-80a4-4453-81ed-c3627dea3919',
isDraft: false,
quantity: 115,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 103.543,
updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 1,
unitPriceInAssetProfileCurrency: 103.543,
value: 11907.445,
valueInBaseCurrency: 11907.445
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-08-07T13:40:39.103Z'),
currency: 'USD',
date: new Date('2024-08-07T13:38:06.289Z'),
fee: 2.97,
id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec',
isDraft: false,
quantity: 105,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 110.24,
updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 2.97,
feeInBaseCurrency: 2.97,
unitPriceInAssetProfileCurrency: 110.24,
value: 11575.2,
valueInBaseCurrency: 11575.2
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
date: new Date('2024-03-12T15:14:38.597Z'),
fee: 45.29,
id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2',
isDraft: false,
quantity: 167,
symbolProfileId: '888d4123-db9a-42f3-9775-01b1ae6f9092',
type: 'BUY',
unitPrice: 41.0596,
updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
comment: null,
countries: [],
createdAt: new Date('2024-03-12T15:15:21.217Z'),
currency: 'USD',
cusip: '463918102',
dataSource: 'YAHOO',
figi: 'BBG01KYQ6PV3',
figiComposite: 'BBG01KYQ6PV3',
figiShareClass: 'BBG01KYQ6QS5',
holdings: [],
id: '888d4123-db9a-42f3-9775-01b1ae6f9092',
isActive: true,
isin: 'CA4639181029',
name: 'iShares Bitcoin Trust',
updatedAt: new Date('2025-09-29T03:14:07.742Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'IBIT',
symbolMapping: {},
url: 'https://www.ishares.com',
userId: null,
activitiesCount: 6,
dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 45.29,
feeInBaseCurrency: 45.29,
unitPriceInAssetProfileCurrency: 41.0596,
value: 6856.9532,
valueInBaseCurrency: 6856.9532
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2024-02-23T15:53:46.907Z'),
currency: 'USD',
date: new Date('2024-02-23T15:53:15.745Z'),
fee: 3,
id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a',
isDraft: false,
quantity: 81,
symbolProfileId: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
type: 'BUY',
unitPrice: 67.995,
updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
comment: 'No data',
countries: [],
createdAt: new Date('2022-04-13T20:05:47.301Z'),
currency: 'USD',
cusip: '92206C565',
dataSource: 'YAHOO',
figi: 'BBG00LWSF7T3',
figiComposite: 'BBG00LWSF7T3',
figiShareClass: 'BBG00LWSF8K0',
holdings: [],
id: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9',
isActive: true,
isin: 'US92206C5655',
name: 'Vanguard Total World Bond ETF',
updatedAt: new Date('2025-10-02T06:02:56.314Z'),
sectors: [],
symbol: 'BNDW',
symbolMapping: {},
url: 'https://vanguard.com',
userId: null,
activitiesCount: 38,
dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z')
},
tags: [],
feeInAssetProfileCurrency: 3,
feeInBaseCurrency: 3,
unitPriceInAssetProfileCurrency: 67.995,
value: 5507.595,
valueInBaseCurrency: 5507.595
},
{
accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e',
comment: null,
createdAt: new Date('2023-01-11T14:35:22.325Z'),
currency: 'USD',
date: new Date('2023-01-11T14:34:55.174Z'),
fee: 7.38,
id: '3fe87b3f-78de-407a-bc02-4189b221051f',
isDraft: false,
quantity: 55,
symbolProfileId: '21746431-d612-4298-911c-3099b2a43003',
type: 'BUY',
unitPrice: 89.48,
updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: {
balance: 150.2,
comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: null,
countries: [],
createdAt: new Date('2021-06-06T16:12:20.982Z'),
currency: 'USD',
cusip: '922042742',
dataSource: 'YAHOO',
figi: 'BBG000GM5FZ6',
figiComposite: 'BBG000GM5FZ6',
figiShareClass: 'BBG001T2YZG9',
holdings: [],
id: '21746431-d612-4298-911c-3099b2a43003',
isActive: true,
isin: 'US9220427424',
name: 'Vanguard Total World Stock Index Fund ETF Shares',
updatedAt: new Date('2025-10-01T20:09:39.500Z'),
scraperConfiguration: null,
sectors: [],
symbol: 'VT',
symbolMapping: {},
url: 'https://www.vanguard.com',
userId: null,
activitiesCount: 267,
dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z')
},
tags: [],
feeInAssetProfileCurrency: 7.38,
feeInBaseCurrency: 7.38,
unitPriceInAssetProfileCurrency: 89.48,
value: 4921.4,
valueInBaseCurrency: 4921.4
}
];
const dataSource = new MatTableDataSource<Activity>(activities);
export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
moduleMetadata({
imports: [
CommonModule,
GfActivityTypeComponent,
GfEntityLogoComponent,
GfNoTransactionsInfoComponent,
GfSymbolPipe,
GfValueComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
RouterModule.forChild([])
],
providers: [NotificationService]
})
]
} as Meta<GfActivitiesTableComponent>;
type Story = StoryObj<GfActivitiesTableComponent>;
export const Loading: Story = {
args: {
baseCurrency: 'USD',
dataSource: undefined,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 0
}
};
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};
export const Pagination: Story = {
args: {
baseCurrency: 'USD',
dataSource: new MatTableDataSource<Activity>(
Array.from({ length: 50 }).map((_, i) => ({
...(activities[i % activities.length] as Activity),
date: new Date(2025, 5, (i % 28) + 1),
id: `${i}`
}))
),
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: false,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: false,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: false,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: 50
}
};

63
libs/ui/src/lib/assistant/assistant.component.ts

@ -169,6 +169,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
public tags: Filter[] = []; public tags: Filter[] = [];
private readonly PRESELECTION_DELAY = 100;
private filterTypes: Filter['type'][] = [ private filterTypes: Filter['type'][] = [
'ACCOUNT', 'ACCOUNT',
'ASSET_CLASS', 'ASSET_CLASS',
@ -176,7 +178,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
'SYMBOL', 'SYMBOL',
'TAG' 'TAG'
]; ];
private keyManager: FocusKeyManager<GfAssistantListItemComponent>; private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private preselectionTimeout: ReturnType<typeof setTimeout>;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -344,6 +348,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.subscribe({ .subscribe({
next: (searchResults) => { next: (searchResults) => {
this.searchResults = searchResults; this.searchResults = searchResults;
this.preselectFirstItem();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}, },
error: (error) => { error: (error) => {
@ -585,6 +592,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public ngOnDestroy() { public ngOnDestroy() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
@ -595,6 +606,58 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
private getFirstSearchResultItem() {
if (this.searchResults.quickLinks?.length > 0) {
return this.searchResults.quickLinks[0];
}
if (this.searchResults.accounts?.length > 0) {
return this.searchResults.accounts[0];
}
if (this.searchResults.holdings?.length > 0) {
return this.searchResults.holdings[0];
}
if (this.searchResults.assetProfiles?.length > 0) {
return this.searchResults.assetProfiles[0];
}
return null;
}
private preselectFirstItem() {
if (this.preselectionTimeout) {
clearTimeout(this.preselectionTimeout);
}
this.preselectionTimeout = setTimeout(() => {
if (!this.isOpen || !this.searchFormControl.value) {
return;
}
const firstItem = this.getFirstSearchResultItem();
if (!firstItem) {
return;
}
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.setFirstItemActive();
const currentFocusedItem = this.getCurrentAssistantListItem();
if (currentFocusedItem) {
currentFocusedItem.focus();
}
this.changeDetectorRef.markForCheck();
}, this.PRESELECTION_DELAY);
}
private searchAccounts(aSearchTerm: string): Observable<ISearchResultItem[]> { private searchAccounts(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService return this.dataService
.fetchAccounts({ .fetchAccounts({

2
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -1,4 +1,3 @@
import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -34,7 +33,6 @@ import { GfValueComponent } from '../value/value.component';
imports: [ imports: [
CommonModule, CommonModule,
GfEntityLogoComponent, GfEntityLogoComponent,
GfSymbolPipe,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.205.0", "version": "2.206.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.205.0", "version": "2.206.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

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

Loading…
Cancel
Save