Browse Source

Merge branch 'main' into task/upgrade-prettier-to-version-3.8.4

pull/7051/head
Thomas Kaul 3 weeks ago
committed by GitHub
parent
commit
20b0705a3c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 26
      CHANGELOG.md
  2. 8
      apps/api/src/app/activities/activities.service.ts
  3. 15
      apps/api/src/app/import/import.service.ts
  4. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 2
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  6. 6
      apps/api/src/services/data-provider/data-provider.service.ts
  7. 1
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  8. 45
      apps/api/src/services/data-provider/manual/manual.service.ts
  9. 74
      apps/api/src/services/market-data/market-data.service.ts
  10. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  11. 7
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  12. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  13. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  14. 30
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  15. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  16. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  17. 52
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  18. 54
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  19. 2
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  20. 8
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  21. 5
      libs/common/src/lib/config.ts
  22. 4
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  23. 40
      package-lock.json
  24. 8
      package.json

26
CHANGELOG.md

@ -11,7 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `prettier` from version `3.8.3` to `3.8.4` - Upgraded `prettier` from version `3.8.3` to `3.8.4`
## 3.15.0 - 2026-06-23 ## 3.16.0 - 2026-06-24
### Added
- Extended the user account settings with a copy-to-clipboard button for the user id
- Added pagination to the platform management of the admin control panel
- Added pagination to the tag management of the admin control panel
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the ISIN number
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the symbol
### Changed
- Improved the throughput of the market data gathering queue by applying the rate limit per data source
- Decreased the rate limiter duration of the market data gathering queue jobs from 4 to 3 seconds
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.2.2` to `13.3`
### Fixed
- Fixed an issue with hourly market data updates not refreshing prices for asset profiles with `MANUAL` data source
- Fixed an issue with the log context formatting in the performance logging service
## 3.15.1 - 2026-06-23
### Changed ### Changed
@ -24,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol - Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol
- Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel - Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel
- Fixed the persistence of an empty `locale` string in the scraper configuration - Fixed the persistence of an empty `locale` string in the scraper configuration
- Fixed a transaction timeout that prevented gathering historical market data for symbols with a long history
- Fixed an exception in various portfolio endpoints when historical exchange rate data is missing
## 3.14.0 - 2026-06-22 ## 3.14.0 - 2026-06-22

8
apps/api/src/app/activities/activities.service.ts

@ -746,10 +746,10 @@ export class ActivitiesService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [ const [
feeInAssetProfileCurrency, feeInAssetProfileCurrency = 0,
feeInBaseCurrency, feeInBaseCurrency = 0,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency = 0,
valueInBaseCurrency valueInBaseCurrency = 0
] = await Promise.all([ ] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate( this.exchangeRateDataService.toCurrencyAtDate(
order.fee, order.fee,

15
apps/api/src/app/import/import.service.ts

@ -592,18 +592,19 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( const valueInBaseCurrency =
value, (await this.exchangeRateDataService.toCurrencyAtDate(
currency ?? assetProfile.currency, value,
userCurrency, currency ?? assetProfile.currency,
date userCurrency,
); date
)) ?? 0;
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
valueInBaseCurrency: await valueInBaseCurrency, valueInBaseCurrency,
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile SymbolProfile: assetProfile
}); });

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

@ -205,21 +205,21 @@ export class PortfolioService {
switch (type) { switch (type) {
case ActivityType.DIVIDEND: case ActivityType.DIVIDEND:
dividendInBaseCurrency += dividendInBaseCurrency +=
await this.exchangeRateDataService.toCurrencyAtDate( (await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency, currency ?? SymbolProfile.currency,
userCurrency, userCurrency,
date date
); )) ?? 0;
break; break;
case ActivityType.INTEREST: case ActivityType.INTEREST:
interestInBaseCurrency += interestInBaseCurrency +=
await this.exchangeRateDataService.toCurrencyAtDate( (await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice, unitPrice,
currency ?? SymbolProfile.currency, currency ?? SymbolProfile.currency,
userCurrency, userCurrency,
date date
); )) ?? 0;
break; break;
} }
@ -957,7 +957,6 @@ export class PortfolioService {
marketPrice, marketPrice,
marketPriceMax, marketPriceMax,
marketPriceMin, marketPriceMin,
SymbolProfile,
tags, tags,
assetProfile: { assetProfile: {
assetClass: SymbolProfile.assetClass, assetClass: SymbolProfile.assetClass,

2
apps/api/src/interceptors/performance-logging/performance-logging.service.ts

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable() @Injectable()
export class PerformanceLoggingService { export class PerformanceLoggingService {
private readonly logger = new Logger(PerformanceLoggingService.name); private readonly logger = new Logger();
public logPerformance({ public logPerformance({
className, className,

6
apps/api/src/services/data-provider/data-provider.service.ts

@ -662,7 +662,11 @@ export class DataProviderService implements OnModuleInit {
); );
const promise = Promise.resolve( const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) dataProvider.getQuotes({
requestTimeout,
useCache,
symbols: symbolsChunk
})
); );
promises.push( promises.push(

1
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -75,6 +75,7 @@ export interface GetHistoricalParams {
export interface GetQuotesParams { export interface GetQuotesParams {
requestTimeout?: number; requestTimeout?: number;
symbols: string[]; symbols: string[];
useCache?: boolean;
} }
export interface GetSearchParams { export interface GetSearchParams {

45
apps/api/src/services/data-provider/manual/manual.service.ts

@ -136,7 +136,8 @@ export class ManualService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
symbols symbols,
useCache = true
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> {
const response: { [symbol: string]: DataProviderResponse } = {}; const response: { [symbol: string]: DataProviderResponse } = {};
@ -164,32 +165,32 @@ export class ManualService implements DataProviderInterface {
} }
}); });
const symbolProfilesWithScraperConfigurationAndInstantMode = const symbolProfilesToScrape = symbolProfiles.filter(
symbolProfiles.filter(({ scraperConfiguration }) => { ({ scraperConfiguration }) => {
return ( return (
scraperConfiguration?.mode === 'instant' && (scraperConfiguration?.mode === 'instant' || !useCache) &&
scraperConfiguration?.selector && scraperConfiguration?.selector &&
scraperConfiguration?.url scraperConfiguration?.url
); );
}); }
);
const scraperResultPromises =
symbolProfilesWithScraperConfigurationAndInstantMode.map( const scraperResultPromises = symbolProfilesToScrape.map(
async ({ scraperConfiguration, symbol }) => { async ({ scraperConfiguration, symbol }) => {
try { try {
const marketPrice = await this.scrape({ const marketPrice = await this.scrape({
scraperConfiguration, scraperConfiguration,
symbol symbol
}); });
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}` `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
); );
return { symbol, marketPrice: undefined }; return { symbol, marketPrice: undefined };
}
} }
); }
);
// Wait for all scraping requests to complete concurrently // Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises); const scraperResults = await Promise.all(scraperResultPromises);

74
apps/api/src/services/market-data/market-data.service.ts

@ -1,6 +1,7 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config';
import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateMarketDataDto } from '@ghostfolio/common/dtos';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -155,49 +156,52 @@ export class MarketDataService {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) { }: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) {
await this.prismaService.$transaction(async (prisma) => { await this.prismaService.$transaction(
if (data.length > 0) { async (prisma) => {
let minTime = Infinity; if (data.length > 0) {
let maxTime = -Infinity; let minTime = Infinity;
let maxTime = -Infinity;
for (const { date } of data) { for (const { date } of data) {
const time = (date as Date).getTime(); const time = (date as Date).getTime();
if (time < minTime) { if (time < minTime) {
minTime = time; minTime = time;
} }
if (time > maxTime) { if (time > maxTime) {
maxTime = time; maxTime = time;
}
} }
}
const minDate = new Date(minTime); const minDate = new Date(minTime);
const maxDate = new Date(maxTime); const maxDate = new Date(maxTime);
await prisma.marketData.deleteMany({ await prisma.marketData.deleteMany({
where: { where: {
dataSource, dataSource,
symbol, symbol,
date: { date: {
gte: minDate, gte: minDate,
lte: maxDate lte: maxDate
}
} }
} });
});
await prisma.marketData.createMany({ await prisma.marketData.createMany({
data: data.map(({ date, marketPrice, state }) => ({ data: data.map(({ date, marketPrice, state }) => ({
dataSource, dataSource,
symbol, symbol,
date: date as Date, date: date as Date,
marketPrice: marketPrice as number, marketPrice: marketPrice as number,
state: state as MarketDataState state: state as MarketDataState
})), })),
skipDuplicates: true skipDuplicates: true
}); });
} }
}); },
{ timeout: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT }
);
} }
public async updateAssetProfileIdentifier( public async updateAssetProfileIdentifier(

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

@ -28,7 +28,8 @@ import { DataGatheringProcessor } from './data-gathering.processor';
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('4 seconds'), duration: ms('3 seconds'),
groupKey: 'dataSource',
max: 1 max: 1
}, },
name: DATA_GATHERING_QUEUE name: DATA_GATHERING_QUEUE

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

@ -165,7 +165,11 @@
</div> </div>
} @else { } @else {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[value]="assetProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
> >
</div> </div>
@ -202,6 +206,7 @@
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
size="medium" size="medium"
[enableCopyToClipboardButton]="true"
[hidden]="!assetProfile?.isin" [hidden]="!assetProfile?.isin"
[value]="assetProfile?.isin" [value]="assetProfile?.isin"
>ISIN</gf-value >ISIN</gf-value

6
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -96,3 +96,9 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

6
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -22,6 +23,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -46,6 +48,7 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
RouterModule RouterModule
@ -59,11 +62,13 @@ export class GfAdminPlatformComponent implements OnInit {
protected dataSource = new MatTableDataSource<Platform>(); protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions']; protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions'];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected platforms: Platform[]; protected platforms: Platform[];
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort); private readonly sort = viewChild.required(MatSort);
private readonly adminService = inject(AdminService); private readonly adminService = inject(AdminService);
@ -145,6 +150,7 @@ export class GfAdminPlatformComponent implements OnInit {
this.platforms = platforms; this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;

30
apps/client/src/app/components/admin-settings/admin-settings.component.scss

@ -10,7 +10,33 @@
} }
.mat-mdc-card { .mat-mdc-card {
--mat-card-outlined-container-color: whitesmoke; --gradient-opacity: 50%;
--mat-card-outlined-outline-color: rgb(193, 219, 254);
background-color: white;
background-image: linear-gradient(
109.6deg,
color-mix(in srgb, rgba(255, 255, 255, 1) 100%, transparent) 11.2%,
color-mix(
in srgb,
rgba(221, 108, 241, 0.26) var(--gradient-opacity),
transparent
)
42%,
color-mix(
in srgb,
rgba(229, 106, 253, 0.71) var(--gradient-opacity),
transparent
)
71.5%,
color-mix(
in srgb,
rgba(123, 183, 253, 1) var(--gradient-opacity),
transparent
)
100%
);
color: rgb(var(--dark-primary-text));
.mat-mdc-card-actions { .mat-mdc-card-actions {
min-height: 0; min-height: 0;
@ -32,6 +58,6 @@
:host-context(.theme-dark) { :host-context(.theme-dark) {
.mat-mdc-card { .mat-mdc-card {
--mat-card-outlined-container-color: #222222; --mat-card-outlined-outline-color: white;
} }
} }

6
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -89,3 +89,9 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

6
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -21,6 +22,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -44,6 +46,7 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
RouterModule RouterModule
@ -62,11 +65,13 @@ export class GfAdminTagComponent implements OnInit {
'activities', 'activities',
'actions' 'actions'
]; ];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[]; protected tags: Tag[];
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort); private readonly sort = viewChild.required(MatSort);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -147,6 +152,7 @@ export class GfAdminTagComponent implements OnInit {
this.tags = tags; this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags); this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;

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

@ -116,6 +116,19 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected accounts: Account[]; protected accounts: Account[];
protected activitiesCount: number; protected activitiesCount: number;
protected assetClass: string; protected assetClass: string;
protected assetProfile: Pick<
EnhancedSymbolProfile,
| 'assetClass'
| 'assetSubClass'
| 'countries'
| 'currency'
| 'dataSource'
| 'isin'
| 'name'
| 'sectors'
| 'symbol'
| 'userId'
>;
protected assetSubClass: string; protected assetSubClass: string;
protected averagePrice: number; protected averagePrice: number;
protected averagePricePrecision = 2; protected averagePricePrecision = 2;
@ -164,7 +177,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}; };
protected sortColumn = 'date'; protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
protected SymbolProfile: EnhancedSymbolProfile;
protected tagsAvailable: Tag[]; protected tagsAvailable: Tag[];
protected readonly translate = translate; protected readonly translate = translate;
protected user: User; protected user: User;
@ -266,6 +278,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
.subscribe( .subscribe(
({ ({
activitiesCount, activitiesCount,
assetProfile,
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dateOfFirstActivity, dateOfFirstActivity,
@ -280,11 +293,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
quantity, quantity,
SymbolProfile,
tags, tags,
value value
}) => { }) => {
this.activitiesCount = activitiesCount; this.activitiesCount = activitiesCount;
this.assetProfile = assetProfile;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if ( if (
@ -322,8 +335,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.user?.permissions, this.user?.permissions,
permissions.readMarketDataOfOwnAssetProfile permissions.readMarketDataOfOwnAssetProfile
) && ) &&
SymbolProfile?.dataSource === 'MANUAL' && assetProfile?.dataSource === 'MANUAL' &&
SymbolProfile?.userId === this.user?.id; assetProfile?.userId === this.user?.id;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
@ -402,7 +415,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (Number.isInteger(this.quantity)) { if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { } else if (assetProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 10) { if (this.quantity < 10) {
this.quantityPrecision = 8; this.quantityPrecision = 8;
} else if (this.quantity < 1000) { } else if (this.quantity < 1000) {
@ -413,7 +426,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile;
this.tags = tags.map((tag) => { this.tags = tags.map((tag) => {
return { return {
@ -427,21 +439,21 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.value = value; this.value = value;
const reportDataGlitchSubject = `Ghostfolio Data Glitch Report${ const reportDataGlitchSubject = `Ghostfolio Data Glitch Report${
this.SymbolProfile?.symbol ? ` (${this.SymbolProfile.symbol})` : '' this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : ''
}`; }`;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.SymbolProfile?.symbol}%0DData Source: ${this.SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
if (this.SymbolProfile?.assetClass) { if (this.assetProfile?.assetClass) {
this.assetClass = translate(this.SymbolProfile?.assetClass); this.assetClass = translate(this.assetProfile?.assetClass);
} }
if (this.SymbolProfile?.assetSubClass) { if (this.assetProfile?.assetSubClass) {
this.assetSubClass = translate(this.SymbolProfile?.assetSubClass); this.assetSubClass = translate(this.assetProfile?.assetSubClass);
} }
if (this.SymbolProfile?.countries?.length > 0) { if (this.assetProfile?.countries?.length > 0) {
for (const country of this.SymbolProfile.countries) { for (const country of this.assetProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {
name: getCountryName({ code: country.code }), name: getCountryName({ code: country.code }),
value: country.weight value: country.weight
@ -449,8 +461,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
} }
if (this.SymbolProfile?.sectors?.length > 0) { if (this.assetProfile?.sectors?.length > 0) {
for (const sector of this.SymbolProfile.sectors) { for (const sector of this.assetProfile.sectors) {
this.sectors[sector.name] = { this.sectors[sector.name] = {
name: translate(sector.name), name: translate(sector.name),
value: sector.weight value: sector.weight
@ -570,12 +582,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
const activity: CreateOrderDto = { const activity: CreateOrderDto = {
accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined, accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined,
comment: undefined, comment: undefined,
currency: this.SymbolProfile?.currency ?? '', currency: this.assetProfile?.currency ?? '',
dataSource: this.SymbolProfile?.dataSource, dataSource: this.assetProfile?.dataSource,
date: today.toISOString(), date: today.toISOString(),
fee: 0, fee: 0,
quantity: this.quantity, quantity: this.quantity,
symbol: this.SymbolProfile?.symbol ?? '', symbol: this.assetProfile?.symbol ?? '',
tags: this.tags.map(({ id }) => { tags: this.tags.map(({ id }) => {
return id; return id;
}), }),
@ -606,7 +618,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: data, content: data,
fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format( fileName: `ghostfolio-export-${this.assetProfile?.symbol}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,

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

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
position="center" position="center"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="SymbolProfile?.name ?? SymbolProfile?.symbol" [title]="assetProfile?.name ?? assetProfile?.symbol"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
/> />
@ -24,11 +24,11 @@
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[benchmarkLabel]="benchmarkLabel" [benchmarkLabel]="benchmarkLabel"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[currency]="SymbolProfile?.currency" [currency]="assetProfile?.currency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isAnimated]="true" [isAnimated]="true"
[label]=" [label]="
isUUID(data.symbol) ? (SymbolProfile?.name ?? data.symbol) : data.symbol isUUID(data.symbol) ? (assetProfile?.name ?? data.symbol) : data.symbol
" "
[locale]="data.locale" [locale]="data.locale"
[showGradient]="true" [showGradient]="true"
@ -60,8 +60,8 @@
[value]="netPerformanceWithCurrencyEffect" [value]="netPerformanceWithCurrencyEffect"
> >
@if ( @if (
SymbolProfile?.currency && assetProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency data.baseCurrency !== assetProfile?.currency
) { ) {
Change with currency effect Change with currency effect
} @else { } @else {
@ -80,8 +80,8 @@
[value]="netPerformancePercentWithCurrencyEffect" [value]="netPerformancePercentWithCurrencyEffect"
> >
@if ( @if (
SymbolProfile?.currency && assetProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency data.baseCurrency !== assetProfile?.currency
) { ) {
Performance with currency effect Performance with currency effect
} @else { } @else {
@ -96,7 +96,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="averagePricePrecision" [precision]="averagePricePrecision"
[unit]="SymbolProfile?.currency" [unit]="assetProfile?.currency"
[value]="averagePrice" [value]="averagePrice"
>Average Unit Price</gf-value >Average Unit Price</gf-value
> >
@ -108,7 +108,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="marketPricePrecision" [precision]="marketPricePrecision"
[unit]="SymbolProfile?.currency" [unit]="assetProfile?.currency"
[value]="marketPrice" [value]="marketPrice"
>Market Price</gf-value >Market Price</gf-value
> >
@ -124,7 +124,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="marketPriceMinPrecision" [precision]="marketPriceMinPrecision"
[unit]="SymbolProfile?.currency" [unit]="assetProfile?.currency"
[value]="marketPriceMin" [value]="marketPriceMin"
>Minimum Price</gf-value >Minimum Price</gf-value
> >
@ -140,7 +140,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="marketPriceMaxPrecision" [precision]="marketPriceMaxPrecision"
[unit]="SymbolProfile?.currency" [unit]="assetProfile?.currency"
[value]="marketPriceMax" [value]="marketPriceMax"
>Maximum Price</gf-value >Maximum Price</gf-value
> >
@ -250,23 +250,23 @@
> >
</div> </div>
@if ( @if (
SymbolProfile?.countries?.length > 0 || assetProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0 assetProfile?.sectors?.length > 0
) { ) {
@if ( @if (
SymbolProfile?.countries?.length === 1 && assetProfile?.countries?.length === 1 &&
SymbolProfile?.sectors?.length === 1 assetProfile?.sectors?.length === 1
) { ) {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="translate(SymbolProfile.sectors[0].name)" [value]="translate(assetProfile.sectors[0].name)"
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
@if (SymbolProfile?.countries?.length === 1) { @if (assetProfile?.countries?.length === 1) {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -274,7 +274,7 @@
[locale]="data.locale" [locale]="data.locale"
[value]=" [value]="
getCountryName({ getCountryName({
code: SymbolProfile.countries[0].code code: assetProfile.countries[0].code
}) })
" "
>Country</gf-value >Country</gf-value
@ -313,8 +313,8 @@
i18n i18n
size="medium" size="medium"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[hidden]="!SymbolProfile?.symbol" [hidden]="!assetProfile?.symbol"
[value]="SymbolProfile?.symbol" [value]="assetProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
> >
</div> </div>
@ -322,8 +322,8 @@
<gf-value <gf-value
size="medium" size="medium"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[hidden]="!SymbolProfile?.isin" [hidden]="!assetProfile?.isin"
[value]="SymbolProfile?.isin" [value]="assetProfile?.isin"
>ISIN</gf-value >ISIN</gf-value
> >
</div> </div>
@ -404,12 +404,12 @@
<div class="d-none d-sm-block ml-2" i18n>Market Data</div> <div class="d-none d-sm-block ml-2" i18n>Market Data</div>
</ng-template> </ng-template>
<gf-historical-market-data-editor <gf-historical-market-data-editor
[currency]="SymbolProfile?.currency" [currency]="assetProfile?.currency"
[dataSource]="SymbolProfile?.dataSource" [dataSource]="assetProfile?.dataSource"
[dateOfFirstActivity]="dateOfFirstActivity" [dateOfFirstActivity]="dateOfFirstActivity"
[locale]="data.locale" [locale]="data.locale"
[marketData]="marketDataItems" [marketData]="marketDataItems"
[symbol]="SymbolProfile?.symbol" [symbol]="assetProfile?.symbol"
[user]="user" [user]="user"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
/> />
@ -462,8 +462,8 @@
mat-stroked-button mat-stroked-button
[queryParams]="{ [queryParams]="{
assetProfileDialog: true, assetProfileDialog: true,
dataSource: SymbolProfile?.dataSource, dataSource: assetProfile?.dataSource,
symbol: SymbolProfile?.symbol symbol: assetProfile?.symbol
}" }"
[routerLink]="routerLinkAdminControlMarketData" [routerLink]="routerLinkAdminControlMarketData"
(click)="onClose()" (click)="onClose()"

2
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -12,6 +12,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -51,6 +52,7 @@ import { catchError } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, FormsModule,
GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,

8
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -260,7 +260,13 @@
<div class="pr-1 w-50"> <div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>
</div> </div>
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div> <div class="pl-1 w-50">
<gf-value
class="text-monospace"
[enableCopyToClipboardButton]="true"
[value]="user?.id"
/>
</div>
</div> </div>
<div class="align-items-center d-flex py-1"> <div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div> <div class="pr-1 w-50"></div>

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

@ -90,9 +90,12 @@ export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333; export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT =
ms('1 minute');
export const DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT =
ms('30 seconds');
export const DEFAULT_REDACTED_PATHS = [ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].balance', 'accounts[*].balance',

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

@ -44,10 +44,6 @@ export interface PortfolioHoldingResponse {
netPerformanceWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number;
performances: Benchmark['performances']; performances: Benchmark['performances'];
quantity: number; quantity: number;
/* @deprecated */
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[]; tags: Tag[];
value: number; value: number;
} }

40
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.15.0", "version": "3.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.15.0", "version": "3.16.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -43,8 +43,8 @@
"@openrouter/ai-sdk-provider": "2.9.1", "@openrouter/ai-sdk-provider": "2.9.1",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0", "@prisma/client": "7.8.0",
"@simplewebauthn/browser": "13.2.2", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.2.2", "@simplewebauthn/server": "13.3.1",
"ai": "6.0.174", "ai": "6.0.174",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"big.js": "7.0.1", "big.js": "7.0.1",
@ -129,7 +129,7 @@
"@storybook/addon-themes": "10.1.10", "@storybook/addon-themes": "10.1.10",
"@storybook/angular": "10.1.10", "@storybook/angular": "10.1.10",
"@trivago/prettier-plugin-sort-imports": "6.0.2", "@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/big.js": "6.2.2", "@types/big.js": "7.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/fast-redact": "3.0.4", "@types/fast-redact": "3.0.4",
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",
@ -13296,25 +13296,25 @@
} }
}, },
"node_modules/@simplewebauthn/browser": { "node_modules/@simplewebauthn/browser": {
"version": "13.2.2", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/server": { "node_modules/@simplewebauthn/server": {
"version": "13.2.2", "version": "13.3.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz",
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hexagon/base64": "^1.1.27", "@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2", "@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.13.0" "@peculiar/x509": "^1.14.3"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -14020,9 +14020,9 @@
} }
}, },
"node_modules/@types/big.js": { "node_modules/@types/big.js": {
"version": "6.2.2", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-7.0.0.tgz",
"integrity": "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==", "integrity": "sha512-WfAGp7IbJvyB8EmWK4tJD24rJRAL6uVbw3LV/hJntFNam+os9KWKj0PzXo8rRRpjupYK8U0M8FoBB8dBhWF2dg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.15.0", "version": "3.16.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",
@ -87,8 +87,8 @@
"@openrouter/ai-sdk-provider": "2.9.1", "@openrouter/ai-sdk-provider": "2.9.1",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0", "@prisma/client": "7.8.0",
"@simplewebauthn/browser": "13.2.2", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.2.2", "@simplewebauthn/server": "13.3.1",
"ai": "6.0.174", "ai": "6.0.174",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"big.js": "7.0.1", "big.js": "7.0.1",
@ -173,7 +173,7 @@
"@storybook/addon-themes": "10.1.10", "@storybook/addon-themes": "10.1.10",
"@storybook/angular": "10.1.10", "@storybook/angular": "10.1.10",
"@trivago/prettier-plugin-sort-imports": "6.0.2", "@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/big.js": "6.2.2", "@types/big.js": "7.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/fast-redact": "3.0.4", "@types/fast-redact": "3.0.4",
"@types/google-spreadsheet": "3.1.5", "@types/google-spreadsheet": "3.1.5",

Loading…
Cancel
Save