Browse Source

Merge branch 'main' into task/remove-deprecated-first-order-date-from-portfolio-performance-endpoint-response

pull/7483/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
cac3aacade
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 41
      CHANGELOG.md
  2. 43
      apps/api/src/app/activities/activities-filter.dto.ts
  3. 83
      apps/api/src/app/activities/activities.controller.ts
  4. 9
      apps/api/src/app/activities/activities.service.ts
  5. 27
      apps/api/src/app/activities/get-activities.dto.ts
  6. 7
      apps/api/src/app/admin/admin.service.ts
  7. 7
      apps/api/src/app/auth/api-key.strategy.ts
  8. 7
      apps/api/src/app/auth/jwt.strategy.ts
  9. 12
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  10. 21
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  11. 39
      apps/api/src/app/export/export.controller.ts
  12. 6
      apps/api/src/app/export/export.service.ts
  13. 14
      apps/api/src/app/export/get-export.dto.ts
  14. 4
      apps/api/src/app/health/health.service.ts
  15. 1
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  16. 208
      apps/api/src/app/portfolio/portfolio.service.ts
  17. 4
      apps/api/src/app/subscription/subscription.controller.ts
  18. 72
      apps/api/src/app/user/user.service.ts
  19. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  20. 7
      apps/api/src/services/data-provider/data-provider.service.ts
  21. 6
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  22. 70
      apps/api/src/services/property/property.service.ts
  23. 5
      apps/client/src/app/app.component.ts
  24. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  25. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  26. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  27. 33
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  28. 296
      apps/client/src/locales/messages.ca.xlf
  29. 300
      apps/client/src/locales/messages.de.xlf
  30. 296
      apps/client/src/locales/messages.es.xlf
  31. 296
      apps/client/src/locales/messages.fr.xlf
  32. 296
      apps/client/src/locales/messages.it.xlf
  33. 296
      apps/client/src/locales/messages.ja.xlf
  34. 296
      apps/client/src/locales/messages.ko.xlf
  35. 296
      apps/client/src/locales/messages.nl.xlf
  36. 296
      apps/client/src/locales/messages.pl.xlf
  37. 296
      apps/client/src/locales/messages.pt.xlf
  38. 296
      apps/client/src/locales/messages.tr.xlf
  39. 296
      apps/client/src/locales/messages.uk.xlf
  40. 292
      apps/client/src/locales/messages.xlf
  41. 296
      apps/client/src/locales/messages.zh.xlf
  42. 11
      apps/client/src/styles.scss
  43. 6
      apps/client/src/styles/theme.scss
  44. 24
      libs/common/src/lib/calculation-helper.spec.ts
  45. 13
      libs/common/src/lib/calculation-helper.ts
  46. 19
      libs/common/src/lib/config.ts
  47. 5
      libs/common/src/lib/helper.ts
  48. 12
      libs/common/src/lib/types/date-range.type.ts
  49. 28
      libs/ui/src/lib/activities-table/activities-table.component.html
  50. 9
      libs/ui/src/lib/activities-table/activities-table.component.scss
  51. 25
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  52. 19
      libs/ui/src/lib/activities-table/activities-table.component.ts
  53. 9
      libs/ui/src/lib/assistant/assistant.component.ts
  54. 7
      libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html
  55. 34
      libs/ui/src/lib/services/data.service.ts
  56. 8
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  57. 596
      package-lock.json
  58. 10
      package.json

41
CHANGELOG.md

@ -10,6 +10,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response - Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
## 3.38.0 - 2026-07-31
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
- Improved the style of the tabs in the holding detail dialog on mobile
- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile
- Improved the style of the empty state in the _Fear & Greed Index_ component
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America)
## 3.37.0 - 2026-07-30 ## 3.37.0 - 2026-07-30

43
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,43 @@
import { DATE_RANGES } from '@ghostfolio/common/config';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { isString } from 'lodash';
// A named date range or a calendar year like '2024', '2023', '2022', etc.
const DATE_RANGE_PATTERN = new RegExp(`^(${DATE_RANGES.join('|')}|\\d{4})$`);
export class ActivitiesFilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

83
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -29,7 +29,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Post, Post,
Put, Put,
Query, Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities') @Controller('activities')
export class ActivitiesController { export class ActivitiesController {
@ -60,22 +61,38 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query() query: ActivitiesFilterDto
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
return this.activitiesService.deleteActivities({ return this.activitiesService.deleteActivities({
endDate,
filters, filters,
startDate,
types: query.activityTypes,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -108,51 +125,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query() query: GetActivitiesDto
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
): Promise<ActivitiesResponse> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
if (dateRange) { if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); ({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
skip,
sortColumn,
sortDirection,
startDate, startDate,
take,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: query.skip,
sortColumn: query.sortColumn,
sortDirection: query.sortDirection,
take: query.take,
types: query.activityTypes,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

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

@ -361,14 +361,23 @@ export class ActivitiesService {
} }
public async deleteActivities({ public async deleteActivities({
endDate,
filters, filters,
startDate,
types,
userId userId
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const { activities } = await this.getActivities({
endDate,
filters, filters,
startDate,
types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

7
apps/api/src/app/admin/admin.service.ts

@ -107,8 +107,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(

7
apps/api/src/app/auth/api-key.strategy.ts

@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy(
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {

7
apps/api/src/app/auth/jwt.strategy.ts

@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;

12
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -51,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -95,7 +95,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -135,7 +135,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -176,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -215,7 +215,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -252,7 +252,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),

21
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -17,7 +17,10 @@ import {
DERIVED_CURRENCIES DERIVED_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -326,8 +329,12 @@ export class GhostfolioService {
} }
public async incrementDailyRequests({ userId }: { userId: string }) { public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({ await this.prismaService.analytics.upsert({
data: { create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 } dataProviderGhostfolioDailyRequests: { increment: 1 }
}, },
where: { userId } where: { userId }
@ -340,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return results; return results;
} }
@ -348,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService.search({ dataProviderService.search({

39
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
@ -31,30 +32,30 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(@Query() query: GetExportDto): Promise<ExportResponse> {
@Query('accounts') filterByAccounts?: string, let endDate: Date;
@Query('activityIds') filterByActivityIds?: string, let startDate: Date;
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, if (query.range) {
@Query('dataSource') filterByDataSource?: string, ({ endDate, startDate } = getIntervalFromDateRange({
@Query('symbol') filterBySymbol?: string, dateRange: query.range
@Query('tags') filterByTags?: string }));
): Promise<ExportResponse> { }
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, endDate,
activityTypes,
filters, filters,
startDate,
activityIds: query.activityIds,
activityTypes: query.activityTypes,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings
}); });

6
apps/api/src/app/export/export.service.ts

@ -25,13 +25,17 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[]; activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}): Promise<ExportResponse> { }): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({ let { activities } = await this.activitiesService.getActivities({
endDate,
filters, filters,
startDate,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

4
apps/api/src/app/health/health.service.ts

@ -26,7 +26,9 @@ export class HealthService {
public async isDatabaseHealthy() { public async isDatabaseHealthy() {
try { try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES); await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true; return true;
} catch { } catch {

1
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -92,6 +92,7 @@ describe('PortfolioService', () => {
null, null,
null, null,
null, null,
null,
null null
); );

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

@ -1124,6 +1124,8 @@ export class PortfolioService {
withSummary: true withSummary: true
}); });
const hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency); return new Big(valueInBaseCurrency);
@ -1183,26 +1185,25 @@ export class PortfolioService {
id: 'rule.currencyClusterRisk.category', id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, Object.values(holdings),
Object.values(holdings), userSettings.language
userSettings.language ),
), new CurrencyClusterRiskCurrentInvestment(
new CurrencyClusterRiskCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, Object.values(holdings),
Object.values(holdings), userSettings.language
userSettings.language )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'assetClassClusterRisk', key: 'assetClassClusterRisk',
@ -1210,26 +1211,25 @@ export class PortfolioService {
id: 'rule.assetClassClusterRisk.category', id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new AssetClassClusterRiskEquity(
new AssetClassClusterRiskEquity( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, Object.values(holdings)
Object.values(holdings) ),
), new AssetClassClusterRiskFixedIncome(
new AssetClassClusterRiskFixedIncome( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, Object.values(holdings)
Object.values(holdings) )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'accountClusterRisk', key: 'accountClusterRisk',
@ -1264,28 +1264,27 @@ export class PortfolioService {
id: 'rule.economicMarketClusterRisk.category', id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new EconomicMarketClusterRiskDevelopedMarkets(
new EconomicMarketClusterRiskDevelopedMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, marketsTotalInBaseCurrency,
marketsTotalInBaseCurrency, markets.developedMarkets.valueInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency, userSettings.language
userSettings.language ),
), new EconomicMarketClusterRiskEmergingMarkets(
new EconomicMarketClusterRiskEmergingMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, marketsTotalInBaseCurrency,
marketsTotalInBaseCurrency, markets.emergingMarkets.valueInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency, userSettings.language
userSettings.language )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'regionalMarketClusterRisk', key: 'regionalMarketClusterRisk',
@ -1293,49 +1292,48 @@ export class PortfolioService {
id: 'rule.regionalMarketClusterRisk.category', id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new RegionalMarketClusterRiskAsiaPacific(
new RegionalMarketClusterRiskAsiaPacific( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.asiaPacific.valueInBaseCurrency
marketsAdvanced.asiaPacific.valueInBaseCurrency ),
), new RegionalMarketClusterRiskEmergingMarkets(
new RegionalMarketClusterRiskEmergingMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.emergingMarkets.valueInBaseCurrency
marketsAdvanced.emergingMarkets.valueInBaseCurrency ),
), new RegionalMarketClusterRiskEurope(
new RegionalMarketClusterRiskEurope( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.europe.valueInBaseCurrency
marketsAdvanced.europe.valueInBaseCurrency ),
), new RegionalMarketClusterRiskJapan(
new RegionalMarketClusterRiskJapan( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.japan.valueInBaseCurrency
marketsAdvanced.japan.valueInBaseCurrency ),
), new RegionalMarketClusterRiskNorthAmerica(
new RegionalMarketClusterRiskNorthAmerica( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.northAmerica.valueInBaseCurrency
marketsAdvanced.northAmerica.valueInBaseCurrency )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'fees', key: 'fees',

4
apps/api/src/app/subscription/subscription.controller.ts

@ -54,7 +54,9 @@ export class SubscriptionController {
} }
let coupons = let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? []; (await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => { const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode; return currentCoupon.code === couponCode;

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

@ -31,9 +31,12 @@ import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
@ -50,15 +53,18 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, Settings, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -67,7 +73,9 @@ export class UserService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService,
@InjectThrottlerStorage()
private readonly throttlerStorage: ThrottlerStorage
) {} ) {}
public async count(args?: Prisma.UserCountArgs) { public async count(args?: Prisma.UserCountArgs) {
@ -228,6 +236,38 @@ export class UserService {
return usersWithAdminRole.length > 0; return usersWithAdminRole.length > 0;
} }
public async isDailyRequestLimitExceeded({
user
}: {
user: UserWithSettings;
}) {
if (user.subscription?.type === SubscriptionType.Premium) {
return false;
}
const maxDailyRequests = await this.getMaxDailyRequests();
if (maxDailyRequests === undefined) {
return false;
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
THROTTLE_DAILY_TTL,
THROTTLE_DAILY_KEY
);
return isBlocked;
} catch (error) {
this.logger.error(error);
return false;
}
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
@ -286,7 +326,7 @@ export class UserService {
updatedAt, updatedAt,
activityCount: analytics?.activityCount, activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests: dataProviderGhostfolioDailyRequests:
analytics?.dataProviderGhostfolioDailyRequests analytics?.dataProviderGhostfolioDailyRequests ?? 0
}; };
if (user.settings) { if (user.settings) {
@ -782,4 +822,26 @@ export class UserService {
return settings; return settings;
} }
private async getMaxDailyRequests() {
const value = await this.propertyService.getByKey<string>(
PROPERTY_MAX_DAILY_REQUESTS
);
if (isNil(value) || value === '') {
return undefined;
}
const maxDailyRequests = Number(value);
if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) {
this.logger.warn(
`The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied`
);
return undefined;
}
return maxDailyRequests;
}
} }

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -159,7 +159,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => { benchmarks = benchmarks.filter(({ symbolProfileId }) => {

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

@ -20,7 +20,8 @@ import {
getCurrencyFromSymbol, getCurrencyFromSymbol,
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
isDerivedCurrency isDerivedCurrency,
isValidSearchQuery
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -838,7 +839,9 @@ export class DataProviderService implements OnModuleInit {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = []; const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return { items: lookupItems }; return { items: lookupItems };
} }

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

@ -330,7 +330,11 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
this.logger.error(error); if (error?.name === 'BadRequestError') {
this.logger.warn(`Could not search for "${query}": ${error.message}`);
} else {
this.logger.error(error);
}
} }
return { items }; return { items };

70
apps/api/src/services/property/property.service.ts

@ -6,27 +6,39 @@ import {
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms from 'ms';
import { PropertyValue } from './interfaces/interfaces'; import { PropertyValue } from './interfaces/interfaces';
@Injectable() @Injectable()
export class PropertyService { export class PropertyService {
private static readonly CACHE_TTL = ms('1 minute');
private cachedProperties: Promise<Property[]>;
private cachedPropertiesExpiresAt: Date;
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: PropertyKey }) { public async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({ const property = await this.prismaService.property.delete({
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
} }
public async get() { public async get({ skipCache = false } = {}) {
const response: { const response: {
[key: string]: PropertyValue; [key: string]: PropertyValue;
} = { } = {
[PROPERTY_CURRENCIES]: [] [PROPERTY_CURRENCIES]: []
}; };
const properties = await this.prismaService.property.findMany(); const properties = await this.getProperties({ skipCache });
for (const property of properties) { for (const property of properties) {
let value = property.value; let value = property.value;
@ -41,8 +53,11 @@ export class PropertyService {
return response; return response;
} }
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) { public async getByKey<TValue extends PropertyValue>(
const properties = await this.get(); aKey: PropertyKey,
{ skipCache = false } = {}
) {
const properties = await this.get({ skipCache });
return properties[aKey] as TValue; return properties[aKey] as TValue;
} }
@ -53,10 +68,53 @@ export class PropertyService {
} }
public async put({ key, value }: { key: PropertyKey; value: string }) { public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({ const property = await this.prismaService.property.upsert({
create: { key, value }, create: { key, value },
update: { value }, update: { value },
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
}
/**
* Returns the properties from the in-memory cache, falling back to the
* database. Callers which write back a modified property must set
* skipCache to avoid basing the write on a stale read.
*/
private async getProperties({ skipCache = false } = {}) {
if (skipCache) {
return this.prismaService.property.findMany();
}
if (
this.cachedProperties &&
isBefore(new Date(), this.cachedPropertiesExpiresAt)
) {
return this.cachedProperties;
}
const properties = this.prismaService.property.findMany().catch((error) => {
if (this.cachedProperties === properties) {
this.invalidateCache();
}
throw error;
});
this.cachedProperties = properties;
this.cachedPropertiesExpiresAt = addMilliseconds(
new Date(),
PropertyService.CACHE_TTL
);
return this.cachedProperties;
}
private invalidateCache() {
this.cachedProperties = undefined;
this.cachedPropertiesExpiresAt = undefined;
} }
} }

5
apps/client/src/app/app.component.ts

@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
) { ) {
this.hasPermissionToChangeDateRange = true; this.hasPermissionToChangeDateRange = true;

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -33,6 +33,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
class="mb-4" class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
<mat-tab> <mat-tab>

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

@ -105,6 +105,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true" [dynamicHeight]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >

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

@ -39,6 +39,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
class="mb-4" class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
<mat-tab> <mat-tab>

33
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -92,8 +91,14 @@ export class GfActivitiesPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
const previousDateRange = this.getDateRange();
this.updateUser(state.user); this.updateUser(state.user);
if (previousDateRange !== this.getDateRange()) {
this.pageIndex = 0;
}
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -120,7 +125,11 @@ export class GfActivitiesPageComponent implements OnInit {
protected onDeleteActivities() { protected onDeleteActivities() {
this.dataService this.dataService
.deleteActivities({ .deleteActivities({
filters: this.userService.getFilters() activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange()
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
@ -159,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit {
activityTypes: this.activityTypesFilter.length activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter ? this.activityTypesFilter
: undefined, : undefined,
filters: this.userService.getFilters() filters: this.userService.getFilters(),
range: this.getDateRange()
}; };
} }
@ -277,16 +287,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.dataSource = undefined; this.dataSource = undefined;
this.totalItems = undefined; this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
range,
activityTypes: this.activityTypesFilter.length activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter ? this.activityTypesFilter
: undefined, : undefined,
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.getDateRange(),
skip: this.pageIndex * this.pageSize, skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection, sortDirection: this.sortDirection,
@ -311,12 +318,16 @@ export class GfActivitiesPageComponent implements OnInit {
}); });
} }
private isCalendarYear(dateRange?: DateRange) { private getDateRange() {
if (!dateRange) { const dateRange = this.user?.settings?.dateRange;
return false;
// Omit the date ranges which do not apply to activities: '1d' spans today
// only, while 'max' would exclude drafts dated in the future
if (!dateRange || ['1d', 'max'].includes(dateRange)) {
return undefined;
} }
return /^\d{4}$/.test(dateRange); return dateRange;
} }
private updateUser(aUser: User) { private updateUser(aUser: User) {

296
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

296
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

292
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

11
apps/client/src/styles.scss

@ -411,6 +411,17 @@ ngx-skeleton-loader {
.mdc-dialog__content { .mdc-dialog__content {
--mat-dialog-supporting-text-color: rgba(var(--dark-primary-text)); --mat-dialog-supporting-text-color: rgba(var(--dark-primary-text));
} }
@media (max-width: 575.98px) {
// Tabs fill the available width on mobile
.mat-mdc-tab-group {
.mat-mdc-tab {
flex-grow: 1;
min-width: unset;
padding: 0 0.5rem;
}
}
}
} }
.mat-mdc-fab, .mat-mdc-fab,

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

@ -364,6 +364,12 @@ $gf-typography: (
) )
); );
@include mat.pseudo-checkbox-overrides(
(
full-selected-icon-color: var(--gf-theme-primary-500)
)
);
@include mat.slide-toggle-overrides( @include mat.slide-toggle-overrides(
( (
selected-focus-handle-color: var(--gf-theme-primary-500), selected-focus-handle-color: var(--gf-theme-primary-500),

24
libs/common/src/lib/calculation-helper.spec.ts

@ -1,8 +1,30 @@
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns';
import { getAnnualizedPerformancePercent } from './calculation-helper'; import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from './calculation-helper';
import { DATE_FORMAT } from './helper';
describe('CalculationHelper', () => { describe('CalculationHelper', () => {
describe('interval from date range', () => {
it('Get interval of a calendar year', async () => {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2024'
});
// The boundaries are expressed in the local time zone, therefore the
// calendar days must hold independently of the time zone the tests run in
expect(format(startDate, DATE_FORMAT)).toEqual('2023-12-31');
expect(format(endDate, DATE_FORMAT)).toEqual('2024-12-31');
// The start date is exclusive, hence the first instant of the year is
// part of the interval
expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1);
});
});
describe('annualized performance percentage', () => { describe('annualized performance percentage', () => {
it('Get annualized performance', async () => { it('Get annualized performance', async () => {
expect( expect(

13
libs/common/src/lib/calculation-helper.ts

@ -7,6 +7,7 @@ import {
startOfWeek, startOfWeek,
startOfYear, startOfYear,
subDays, subDays,
subMilliseconds,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isFinite, isNumber } from 'lodash'; import { isFinite, isNumber } from 'lodash';
@ -75,10 +76,16 @@ export function getIntervalFromDateRange(params: {
break; break;
case 'max': case 'max':
break; break;
default: default: {
// '2024', '2023', '2022', etc. // '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(dateRange)); const yearStartDate = new Date(Number(dateRange), 0, 1);
startDate = max([startDate, new Date(dateRange)]);
// Derive the boundaries of the calendar year in the local time zone, as
// the consumers apply local time zone semantics. As the start date is
// exclusive, the last millisecond of the preceding year is used.
endDate = endOfYear(yearStartDate);
startDate = max([startDate, subMilliseconds(yearStartDate, 1)]);
}
} }
return { endDate, startDate }; return { endDate, startDate };

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

@ -67,6 +67,20 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round(
DATA_GATHERING_QUEUE_PRIORITY_LOW / 2 DATA_GATHERING_QUEUE_PRIORITY_LOW / 2
); );
/**
* The named date ranges, complemented by the calendar years like '2024',
* '2023', '2022', etc.
*/
export const DATE_RANGES = [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
] as const;
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE = export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE =
'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE'; 'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE';
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1; export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1;
@ -253,6 +267,7 @@ export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID';
export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED'; export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; 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_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_MAX_DAILY_REQUESTS = 'MAX_DAILY_REQUESTS';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES'; export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES';
@ -287,6 +302,8 @@ export const REPLACE_NAME_PARTS = [
'Xtrackers (IE) Plc -' 'Xtrackers (IE) Plc -'
]; ];
export const SEARCH_QUERY_MINIMUM_LENGTH = 2;
export const SECTORS = [ export const SECTORS = [
'Basic Materials', 'Basic Materials',
'Communication Services', 'Communication Services',
@ -326,6 +343,8 @@ export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd'; 'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const THROTTLE_DAILY_KEY = 'daily';
export const THROTTLE_DAILY_TTL = ms('1 day');
export const THROTTLE_DEFAULT_LIMIT = 10; export const THROTTLE_DEFAULT_LIMIT = 10;
export const THROTTLE_DEFAULT_TTL = ms('1 minute'); export const THROTTLE_DEFAULT_TTL = ms('1 minute');
export const THROTTLE_SIGNUP_LIMIT = 5; export const THROTTLE_SIGNUP_LIMIT = 5;

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

@ -41,6 +41,7 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
import { import {
@ -509,6 +510,10 @@ export function isRootCurrency(aCurrency: string) {
}); });
} }
export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
}
export function parseDate(date: string): Date | undefined { export function parseDate(date: string): Date | undefined {
if (!date) { if (!date) {
return undefined; return undefined;

12
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,3 @@
export type DateRange = import type { DATE_RANGES } from '../config';
| '1d'
| '1y' export type DateRange = (typeof DATE_RANGES)[number] | string; // '2024', '2023', '2022', etc.
| '5y'
| 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc.

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

@ -1,4 +1,6 @@
<div class="d-flex justify-content-end justify-content-lg-between"> <div
class="align-items-center d-flex justify-content-end justify-content-lg-between"
>
<div class="d-none d-lg-flex"> <div class="d-none d-lg-flex">
@if (hasPermissionToFilterByType) { @if (hasPermissionToFilterByType) {
<mat-form-field appearance="outline" class="without-hint"> <mat-form-field appearance="outline" class="without-hint">
@ -56,12 +58,19 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource()?.data.length === 0" [disabled]="!canExportActivities()"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" /> <ion-icon class="mr-2" name="cloud-download-outline" />
<span i18n>Export Activities</span> @if (canExportActivities()) {
<span i18n
>Export {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Export Activities</span>
}
</span> </span>
</button> </button>
} }
@ -82,14 +91,19 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]=" [disabled]="!canDeleteActivities()"
dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
(click)="onDeleteActivities()" (click)="onDeleteActivities()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete Activities</span> @if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span>
}
</span> </span>
</button> </button>
</mat-menu> </mat-menu>

9
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -1,3 +1,12 @@
@use '@angular/material' as mat;
:host { :host {
display: block; display: block;
@include mat.form-field-overrides(
(
container-height: 2rem,
container-vertical-padding: 0.33rem
)
);
} }

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

@ -494,3 +494,28 @@ export const Actions: Story = {
totalItems: activities.length totalItems: activities.length
} }
}; };
export const Toolbar: Story = {
args: {
dataSource,
baseCurrency: 'USD',
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: true,
hasPermissionToDeleteActivity: true,
hasPermissionToExportActivities: true,
hasPermissionToFilterByType: true,
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
}
};

19
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -282,6 +282,20 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
); );
} }
public canDeleteActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToDeleteActivity
);
}
public canExportActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToExportActivities
);
}
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
(activity.account && isAccountExcluded(activity.account)) ?? (activity.account && isAccountExcluded(activity.account)) ??
@ -314,7 +328,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.activitiesDeleted.emit(); this.activitiesDeleted.emit();
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these activities?` title:
this.totalItems === 1
? $localize`Do you really want to delete this activity?`
: $localize`Do you really want to delete these ${this.totalItems}:count: activities?`
}); });
} }

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

@ -202,6 +202,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.searchFormControl.valueChanges this.searchFormControl.valueChanges
.pipe( .pipe(
map((searchTerm) => { map((searchTerm) => {
return searchTerm?.trim();
}),
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this.isLoading = { this.isLoading = {
accounts: true, accounts: true,
assetProfiles: true, assetProfiles: true,
@ -216,11 +221,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
return searchTerm?.trim();
}), }),
debounceTime(300),
distinctUntilChanged(),
switchMap((searchTerm) => { switchMap((searchTerm) => {
const results = { const results = {
accounts: [], accounts: [],

7
libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html

@ -12,10 +12,11 @@
<div> <div>
<div class="h4 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText() }}</span> <span class="mr-2">{{ fearAndGreedIndexText() }}</span>
@let value = fearAndGreedIndex() | number: '1.0-0';
<small class="text-muted" <small class="text-muted"
><strong>{{ ><span [class.font-weight-bold]="value">{{
(fearAndGreedIndex() | number: '1.0-0') ?? placeholder value ?? placeholder
}}</strong }}</span
>/100</small >/100</small
> >
</div> </div>

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

@ -329,8 +329,24 @@ export class DataService {
return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`); return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`);
} }
public deleteActivities({ filters }: { filters?: Filter[] }) { public deleteActivities({
const params = this.buildFiltersAsQueryParams({ filters }); activityTypes,
filters,
range
}: {
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) {
params = params.append('range', range);
}
return this.http.delete<number>('/api/v1/activities', { params }); return this.http.delete<number>('/api/v1/activities', { params });
} }
@ -459,11 +475,13 @@ export class DataService {
public fetchExport({ public fetchExport({
activityIds, activityIds,
activityTypes, activityTypes,
filters filters,
range
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: string[]; activityTypes?: string[];
filters?: Filter[]; filters?: Filter[];
range?: DateRange;
} = {}) { } = {}) {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
@ -475,6 +493,10 @@ export class DataService {
params = params.append('activityTypes', activityTypes.join(',')); params = params.append('activityTypes', activityTypes.join(','));
} }
if (range) {
params = params.append('range', range);
}
return this.http.get<ExportResponse>('/api/v1/export', { return this.http.get<ExportResponse>('/api/v1/export', {
params params
}); });
@ -509,8 +531,7 @@ export class DataService {
public fetchInfo(): InfoItem { public fetchInfo(): InfoItem {
const info = cloneDeep((window as any).info); const info = cloneDeep((window as any).info);
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as
| 'ios' 'ios' | 'trusted-web-activity';
| 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions( info.globalPermissions = filterGlobalPermissions(
info.globalPermissions, info.globalPermissions,
@ -949,8 +970,7 @@ export class DataService {
public updateInfo() { public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => { this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as
| 'ios' 'ios' | 'trusted-web-activity';
| 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions( info.globalPermissions = filterGlobalPermissions(
info.globalPermissions, info.globalPermissions,

8
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -41,6 +41,7 @@ import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
map,
switchMap switchMap
} from 'rxjs/operators'; } from 'rxjs/operators';
@ -128,6 +129,9 @@ export class GfSymbolAutocompleteComponent
this.control.valueChanges this.control.valueChanges
.pipe( .pipe(
map((query) => {
return isString(query) ? query.trim() : query;
}),
filter((query) => { filter((query) => {
if (query?.length === 0) { if (query?.length === 0) {
this.showDefaultOptions(); this.showDefaultOptions();
@ -137,13 +141,13 @@ export class GfSymbolAutocompleteComponent
return isString(query); return isString(query);
}), }),
debounceTime(400),
distinctUntilChanged(),
tap(() => { tap(() => {
this.isLoading = true; this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}), }),
debounceTime(400),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
switchMap((query: string) => { switchMap((query: string) => {
return this.dataService.fetchSymbols({ return this.dataService.fetchSymbols({

596
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.37.0", "version": "3.38.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.37.0", "version": "3.38.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -43,8 +43,8 @@
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.8.0", "@prisma/client": "7.9.1",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -127,7 +127,7 @@
"@nx/storybook": "23.0.2", "@nx/storybook": "23.0.2",
"@nx/web": "23.0.2", "@nx/web": "23.0.2",
"@nx/workspace": "23.0.2", "@nx/workspace": "23.0.2",
"@prisma/config": "7.8.0", "@prisma/config": "7.9.1",
"@schematics/angular": "21.2.6", "@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10", "@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10", "@storybook/addon-themes": "10.1.10",
@ -157,7 +157,7 @@
"nx": "23.0.2", "nx": "23.0.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0", "prisma": "7.9.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.4.0", "replace-in-file": "8.4.0",
@ -3908,33 +3908,33 @@
} }
}, },
"node_modules/@electric-sql/pglite": { "node_modules/@electric-sql/pglite": {
"version": "0.4.1", "version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@electric-sql/pglite-socket": { "node_modules/@electric-sql/pglite-socket": {
"version": "0.1.1", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz",
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"pglite-server": "dist/scripts/server.js" "pglite-server": "dist/scripts/server.js"
}, },
"peerDependencies": { "peerDependencies": {
"@electric-sql/pglite": "0.4.1" "@electric-sql/pglite": "0.4.3"
} }
}, },
"node_modules/@electric-sql/pglite-tools": { "node_modules/@electric-sql/pglite-tools": {
"version": "0.3.1", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz",
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@electric-sql/pglite": "0.4.1" "@electric-sql/pglite": "0.4.3"
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
@ -10041,24 +10041,24 @@
} }
}, },
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz",
"integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", "integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/driver-adapter-utils": "7.8.0", "@prisma/driver-adapter-utils": "7.9.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"postgres-array": "3.0.4" "postgres-array": "3.0.4"
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz",
"integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "integrity": "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/client-runtime-utils": "7.8.0" "@prisma/client-runtime-utils": "7.9.1"
}, },
"engines": { "engines": {
"node": "^20.19 || ^22.12 || >=24.0" "node": "^20.19 || ^22.12 || >=24.0"
@ -10077,15 +10077,15 @@
} }
}, },
"node_modules/@prisma/client-runtime-utils": { "node_modules/@prisma/client-runtime-utils": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.1.tgz",
"integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", "integrity": "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.1.tgz",
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", "integrity": "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -10096,97 +10096,95 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz",
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", "integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/dev": { "node_modules/@prisma/dev": {
"version": "0.24.3", "version": "0.24.17",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz",
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@electric-sql/pglite": "0.4.1", "@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite-socket": "0.1.1", "@electric-sql/pglite-socket": "0.1.3",
"@electric-sql/pglite-tools": "0.3.1", "@electric-sql/pglite-tools": "0.3.3",
"@hono/node-server": "1.19.11",
"@prisma/get-platform": "7.2.0", "@prisma/get-platform": "7.2.0",
"@prisma/query-plan-executor": "7.2.0", "@prisma/query-plan-executor": "7.2.0",
"@prisma/streams-local": "0.1.2", "@prisma/streams-local": "0.1.11",
"find-my-way": "9.7.0",
"foreground-child": "3.3.1", "foreground-child": "3.3.1",
"get-port-please": "3.2.0", "get-port-please": "3.2.0",
"hono": "^4.12.8",
"http-status-codes": "2.3.0",
"pathe": "2.0.3", "pathe": "2.0.3",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"remeda": "2.33.4", "remeda": "2.33.4",
"std-env": "3.10.0", "std-env": "3.10.0",
"valibot": "1.2.0", "valibot": "1.4.2",
"zeptomatch": "2.1.0" "zeptomatch": "2.1.0"
} }
}, },
"node_modules/@prisma/driver-adapter-utils": { "node_modules/@prisma/driver-adapter-utils": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz",
"integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", "integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.8.0" "@prisma/debug": "7.9.1"
} }
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.1.tgz",
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", "integrity": "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.8.0", "@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/fetch-engine": "7.8.0", "@prisma/fetch-engine": "7.9.1",
"@prisma/get-platform": "7.8.0" "@prisma/get-platform": "7.9.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz",
"integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", "integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.8.0" "@prisma/debug": "7.9.1"
} }
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.1.tgz",
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", "integrity": "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.8.0", "@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/get-platform": "7.8.0" "@prisma/get-platform": "7.9.1"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.8.0" "@prisma/debug": "7.9.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
@ -10214,9 +10212,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/streams-local": { "node_modules/@prisma/streams-local": {
"version": "0.1.2", "version": "0.1.11",
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz",
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -10226,7 +10224,7 @@
"proper-lockfile": "^4.1.2" "proper-lockfile": "^4.1.2"
}, },
"engines": { "engines": {
"bun": ">=1.3.6", "bun": ">=1.2.0",
"node": ">=22.0.0" "node": ">=22.0.0"
} }
}, },
@ -10244,14 +10242,23 @@
} }
}, },
"node_modules/@prisma/studio-core": { "node_modules/@prisma/studio-core": {
"version": "0.27.3", "version": "0.33.0",
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz",
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle": "1.1.10",
"chart.js": "4.5.1" "@visx/curve": "4.0.1-alpha.0",
"@visx/event": "4.0.1-alpha.0",
"@visx/grid": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/responsive": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"d3-array": "3.2.4",
"d3-shape": "3.2.0",
"elkjs": "0.11.1"
}, },
"engines": { "engines": {
"node": "^20.19 || ^22.12 || >=24.0", "node": "^20.19 || ^22.12 || >=24.0",
@ -12501,9 +12508,8 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@types/geojson": "*" "@types/geojson": "*"
} }
@ -12531,9 +12537,8 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT", "devOptional": true,
"optional": true, "license": "MIT"
"peer": true
}, },
"node_modules/@types/d3-polygon": { "node_modules/@types/d3-polygon": {
"version": "3.0.2", "version": "3.0.2",
@ -12756,9 +12761,8 @@
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT", "devOptional": true,
"optional": true, "license": "MIT"
"peer": true
}, },
"node_modules/@types/google-spreadsheet": { "node_modules/@types/google-spreadsheet": {
"version": "3.1.5", "version": "3.1.5",
@ -12902,7 +12906,7 @@
"version": "4.17.24", "version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/luxon": { "node_modules/@types/luxon": {
@ -13056,7 +13060,6 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -14168,6 +14171,265 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@visx/curve": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz",
"integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/event": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz",
"integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/point": "4.0.1-alpha.0"
}
},
"node_modules/@visx/grid": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz",
"integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/point": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/group": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz",
"integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/point": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz",
"integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/responsive": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz",
"integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/scale": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz",
"integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/shape": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz",
"integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/vendor": "4.0.0-alpha.0",
"classnames": "^2.3.1",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/vendor": {
"version": "4.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz",
"integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==",
"devOptional": true,
"license": "MIT and ISC",
"dependencies": {
"@types/d3-array": "3.0.3",
"@types/d3-color": "3.1.0",
"@types/d3-delaunay": "6.0.1",
"@types/d3-format": "3.0.1",
"@types/d3-geo": "3.1.0",
"@types/d3-interpolate": "3.0.1",
"@types/d3-path": "3.1.1",
"@types/d3-scale": "4.0.2",
"@types/d3-shape": "3.1.7",
"@types/d3-time": "3.0.0",
"@types/d3-time-format": "2.1.0",
"d3-array": "3.2.1",
"d3-color": "3.1.0",
"d3-delaunay": "6.0.2",
"d3-format": "3.1.0",
"d3-geo": "3.1.0",
"d3-interpolate": "3.0.1",
"d3-path": "3.1.0",
"d3-scale": "4.0.2",
"d3-shape": "3.2.0",
"d3-time": "3.1.0",
"d3-time-format": "4.1.0",
"internmap": "2.0.3"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-time-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-delaunay": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@vitejs/plugin-basic-ssl": { "node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz",
@ -15462,9 +15724,9 @@
} }
}, },
"node_modules/better-result": { "node_modules/better-result": {
"version": "2.8.2", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.2.tgz", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
"integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
@ -16392,6 +16654,13 @@
"validator": "^13.15.22" "validator": "^13.15.22"
} }
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"devOptional": true,
"license": "MIT"
},
"node_modules/clean-css": { "node_modules/clean-css": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@ -18049,8 +18318,7 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.33.2", "version": "3.33.2",
@ -18157,9 +18425,8 @@
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"internmap": "1 - 2" "internmap": "1 - 2"
}, },
@ -18214,9 +18481,8 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -18372,9 +18638,8 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -18408,9 +18673,8 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"d3-color": "1 - 3" "d3-color": "1 - 3"
}, },
@ -18422,9 +18686,8 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -18516,9 +18779,8 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"d3-array": "2.10.0 - 3", "d3-array": "2.10.0 - 3",
"d3-format": "1 - 3", "d3-format": "1 - 3",
@ -18560,9 +18822,8 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"d3-path": "^3.1.0" "d3-path": "^3.1.0"
}, },
@ -18574,9 +18835,8 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"d3-array": "2 - 3" "d3-array": "2 - 3"
}, },
@ -18588,9 +18848,8 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"d3-time": "1 - 3" "d3-time": "1 - 3"
}, },
@ -18943,9 +19202,8 @@
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
@ -19286,6 +19544,13 @@
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
"devOptional": true,
"license": "EPL-2.0"
},
"node_modules/emittery": { "node_modules/emittery": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@ -20460,6 +20725,13 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -20510,6 +20782,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-redact": { "node_modules/fast-redact": {
"version": "3.5.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
@ -20898,6 +21180,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/find-my-way": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -21768,9 +22065,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/grammex": { "node_modules/grammex": {
"version": "3.1.12", "version": "3.1.13",
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz",
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
@ -22645,9 +22942,8 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -29188,17 +29484,17 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "7.8.0", "version": "7.9.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz",
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "integrity": "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "7.8.0", "@prisma/config": "7.9.1",
"@prisma/dev": "0.24.3", "@prisma/dev": "0.24.17",
"@prisma/engines": "7.8.0", "@prisma/engines": "7.9.1",
"@prisma/studio-core": "0.27.3", "@prisma/studio-core": "0.33.0",
"mysql2": "3.15.3", "mysql2": "3.15.3",
"postgres": "3.4.7" "postgres": "3.4.7"
}, },
@ -30152,6 +30448,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/retry": { "node_modules/retry": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@ -30183,9 +30489,8 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense", "devOptional": true,
"optional": true, "license": "Unlicense"
"peer": true
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.4", "version": "1.0.0-rc.4",
@ -30532,6 +30837,29 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"devOptional": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -34374,9 +34702,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/valibot": { "node_modules/valibot": {
"version": "1.2.0", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {

10
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.37.0", "version": "3.38.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 @@
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.8.0", "@prisma/client": "7.9.1",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -171,7 +171,7 @@
"@nx/storybook": "23.0.2", "@nx/storybook": "23.0.2",
"@nx/web": "23.0.2", "@nx/web": "23.0.2",
"@nx/workspace": "23.0.2", "@nx/workspace": "23.0.2",
"@prisma/config": "7.8.0", "@prisma/config": "7.9.1",
"@schematics/angular": "21.2.6", "@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10", "@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10", "@storybook/addon-themes": "10.1.10",
@ -201,7 +201,7 @@
"nx": "23.0.2", "nx": "23.0.2",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0", "prisma": "7.9.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.4.0", "replace-in-file": "8.4.0",

Loading…
Cancel
Save