Browse Source

Merge branch 'main' into patch-1

pull/3724/head
Thomas Kaul 12 months ago
committed by GitHub
parent
commit
080aed5207
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      CHANGELOG.md
  2. 14
      apps/api/src/app/admin/admin.controller.ts
  3. 30
      apps/api/src/app/admin/admin.service.ts
  4. 7
      apps/api/src/app/benchmark/benchmark.service.ts
  5. 29
      apps/api/src/app/info/info.service.ts
  6. 3
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 51
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 7
      apps/api/src/services/configuration/configuration.service.ts
  9. 35
      apps/api/src/services/data-provider/manual/manual.service.ts
  10. 28
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  11. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  12. 5
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  13. 10
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  14. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.module.ts
  15. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  16. 17
      apps/client/src/app/components/admin-users/admin-users.component.ts
  17. 10
      apps/client/src/app/components/admin-users/admin-users.html
  18. 4
      apps/client/src/app/components/admin-users/admin-users.module.ts
  19. 2
      apps/client/src/app/pages/landing/landing-page.html
  20. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  21. 5
      apps/client/src/app/services/admin.service.ts
  22. 22
      apps/client/src/assets/oss-friends.json
  23. 326
      apps/client/src/locales/messages.it.xlf
  24. 3
      libs/common/src/lib/config.ts
  25. 12
      libs/common/src/lib/interfaces/admin-data.interface.ts
  26. 14
      libs/common/src/lib/interfaces/admin-users.interface.ts
  27. 2
      libs/common/src/lib/interfaces/index.ts
  28. 1
      libs/common/src/lib/interfaces/scraper-configuration.interface.ts
  29. 12
      libs/ui/src/lib/carousel/carousel-item.directive.ts
  30. 7
      libs/ui/src/lib/carousel/carousel.component.html
  31. 64
      libs/ui/src/lib/carousel/carousel.component.ts
  32. 2
      package.json

14
CHANGELOG.md

@ -9,13 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the asynchronous operations using `Promise.all()` in the info service
- Optimized the asynchronous operations using `Promise.all()` in the admin control panel endpoint
- Extracted the users from the admin control panel endpoint to a dedicated endpoint
- Improved the language localization for French (`fr`) - Improved the language localization for French (`fr`)
- Improved the language localization for Italian (`it`)
## 2.106.0-beta.5 - 2024-08-31 ## 2.106.0 - 2024-09-07
### Added ### Added
- Set up a performance logging service - Set up a performance logging service
- Added a loading indicator to the queue jobs table in the admin control panel
- Added a loading indicator to the users table in the admin control panel
- Added the attribute `mode` to the scraper configuration to get quotes instantly
### Changed ### Changed
@ -26,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the data format of the environment variable `CACHE_QUOTES_TTL` from seconds to milliseconds - Changed the data format of the environment variable `CACHE_QUOTES_TTL` from seconds to milliseconds
- Changed the data format of the environment variable `CACHE_TTL` from seconds to milliseconds - Changed the data format of the environment variable `CACHE_TTL` from seconds to milliseconds
- Removed the environment variable `MAX_ITEM_IN_CACHE` - Removed the environment variable `MAX_ITEM_IN_CACHE`
- Improved the error logs of the scraper configuration test in the asset profile details dialog of the admin control
- Improved the language localization for Polish (`pl`) - Improved the language localization for Polish (`pl`)
- Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet` - Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet`
- Upgraded `cache-manager` from version `3.4.3` to `5.7.6` - Upgraded `cache-manager` from version `3.4.3` to `5.7.6`
@ -34,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental) - Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental)
- Fixed an issue on the portfolio activities page by loading the data only once
- Fixed an issue in the carousel component for the testimonial section on the landing page
- Fixed the historical market data gathering in the _Yahoo Finance_ service by switching from `historical()` to `chart()`
- Handled an exception in the historical market data component of the asset profile details dialog in the admin control panel
## 2.105.0 - 2024-08-21 ## 2.105.0 - 2024-08-21

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

@ -17,6 +17,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -239,9 +240,11 @@ export class AdminController {
return { price }; return { price };
} }
throw new Error('Could not parse the current market price'); throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})`
);
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'AdminController');
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
} }
@ -345,4 +348,11 @@ export class AdminController {
) { ) {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
@Get('user')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> {
return this.adminService.getUsers();
}
} }

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

@ -21,6 +21,7 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
@ -107,8 +108,7 @@ export class AdminService {
} }
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { const exchangeRates = this.exchangeRateDataService
exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== DEFAULT_CURRENCY; return currency !== DEFAULT_CURRENCY;
@ -131,11 +131,19 @@ export class AdminService {
currency currency
) )
}; };
}), });
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), const [settings, transactionCount, userCount] = await Promise.all([
userCount: await this.prismaService.user.count(), this.propertyService.get(),
users: await this.getUsersWithAnalytics(), this.prismaService.order.count(),
this.prismaService.user.count()
]);
return {
exchangeRates,
settings,
transactionCount,
userCount,
version: environment.version version: environment.version
}; };
} }
@ -377,6 +385,10 @@ export class AdminService {
}; };
} }
public async getUsers(): Promise<AdminUsers> {
return { users: await this.getUsersWithAnalytics() };
}
public async patchAssetProfileData({ public async patchAssetProfileData({
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -546,11 +558,11 @@ export class AdminService {
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = { let orderBy: any = {
createdAt: 'desc' createdAt: 'desc'
}; };
let where; let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = {

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

@ -7,7 +7,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend, calculateBenchmarkTrend,
@ -443,7 +446,7 @@ export class BenchmarkService {
benchmarks, benchmarks,
expiration: expiration.getTime() expiration: expiration.getTime()
}), }),
0 CACHE_TTL_INFINITE
); );
} }

29
apps/api/src/app/info/info.service.ts

@ -54,9 +54,6 @@ export class InfoService {
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean; let isReadOnlyMode: boolean;
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -100,22 +97,30 @@ export class InfoService {
globalPermissions.push(permissions.enableSystemMessage); globalPermissions.push(permissions.enableSystemMessage);
} }
const isUserSignupEnabled = const [
await this.propertyService.isUserSignupEnabled(); benchmarks,
demoAuthToken,
if (isUserSignupEnabled) { isUserSignupEnabled,
globalPermissions.push(permissions.createUserAccount); platforms,
} statistics,
subscriptions,
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] = tags
await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions(), this.getSubscriptions(),
this.tagService.get() this.tagService.get()
]); ]);
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
return { return {
...info, ...info,
benchmarks, benchmarks,

3
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -11,6 +11,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { CACHE_TTL_INFINITE } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getSum, getSum,
@ -882,7 +883,7 @@ export abstract class PortfolioCalculator {
expiration: expiration.getTime(), expiration: expiration.getTime(),
portfolioSnapshot: snapshot portfolioSnapshot: snapshot
})), })),
0 CACHE_TTL_INFINITE
); );
return snapshot; return snapshot;

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

@ -602,14 +602,7 @@ export class PortfolioService {
userId userId
}); });
const orders = activities.filter(({ SymbolProfile }) => { if (activities.length === 0) {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
return { return {
accounts: [], accounts: [],
averagePrice: undefined, averagePrice: undefined,
@ -646,10 +639,8 @@ export class PortfolioService {
]); ]);
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId, userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}),
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency currency: userCurrency
}); });
@ -659,8 +650,8 @@ export class PortfolioService {
const { positions } = await portfolioCalculator.getSnapshot(); const { positions } = await portfolioCalculator.getSnapshot();
const position = positions.find(({ symbol }) => { const position = positions.find(({ dataSource, symbol }) => {
return symbol === aSymbol; return dataSource === aDataSource && symbol === aSymbol;
}); });
if (position) { if (position) {
@ -673,14 +664,22 @@ export class PortfolioService {
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
quantity, quantity,
symbol,
tags, tags,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
transactionCount transactionCount
} = position; } = position;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
);
});
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy( const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => { activitiesOfPosition.filter(({ Account }) => {
return Account; return Account;
}), }),
'Account.id' 'Account.id'
@ -715,8 +714,8 @@ export class PortfolioService {
); );
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(orders[0].unitPrice, marketPrice); let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice); let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice);
if (historicalData[aSymbol]) { if (historicalData[aSymbol]) {
let j = -1; let j = -1;
@ -760,10 +759,10 @@ export class PortfolioService {
} else { } else {
// Add historical entry for buy date, if no historical data available // Add historical entry for buy date, if no historical data available
historicalDataArray.push({ historicalDataArray.push({
averagePrice: orders[0].unitPrice, averagePrice: activitiesOfPosition[0].unitPrice,
date: firstBuyDate, date: firstBuyDate,
marketPrice: orders[0].unitPrice, marketPrice: activitiesOfPosition[0].unitPrice,
quantity: orders[0].quantity quantity: activitiesOfPosition[0].quantity
}); });
} }
@ -773,7 +772,6 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
orders,
SymbolProfile, SymbolProfile,
tags, tags,
transactionCount, transactionCount,
@ -805,6 +803,7 @@ export class PortfolioService {
]?.toNumber(), ]?.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition,
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(), quantity.mul(marketPrice ?? 0).toNumber(),
@ -862,7 +861,6 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
orders,
SymbolProfile, SymbolProfile,
accounts: [], accounts: [],
averagePrice: 0, averagePrice: 0,
@ -882,6 +880,7 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined, netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined, netPerformanceWithCurrencyEffect: undefined,
orders: [],
quantity: 0, quantity: 0,
tags: [], tags: [],
transactionCount: undefined, transactionCount: undefined,
@ -912,7 +911,7 @@ export class PortfolioService {
userCurrency: this.getUserCurrency() userCurrency: this.getUserCurrency()
}); });
if (activities?.length <= 0) { if (activities.length === 0) {
return { return {
hasErrors: false, hasErrors: false,
positions: [] positions: []
@ -1037,14 +1036,12 @@ export class PortfolioService {
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId, impersonationId,
portfolioCalculator,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
@ -1089,7 +1086,7 @@ export class PortfolioService {
userId userId
}); });
if (accountBalanceItems?.length <= 0 && activities?.length <= 0) { if (accountBalanceItems.length === 0 && activities.length === 0) {
return { return {
chart: [], chart: [],
firstOrderDate: undefined, firstOrderDate: undefined,
@ -1106,9 +1103,7 @@ export class PortfolioService {
}; };
} }
portfolioCalculator = const portfolioCalculator = this.calculatorFactory.createCalculator({
portfolioCalculator ??
this.calculatorFactory.createCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
filters, filters,

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

@ -1,5 +1,8 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import {
CACHE_TTL_NO_CACHE,
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -22,7 +25,7 @@ export class ConfigurationService {
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ DATA_SOURCES: json({

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

@ -166,11 +166,42 @@ export class ManualService implements DataProviderInterface {
} }
}); });
const symbolProfilesWithScraperConfigurationAndInstantMode =
symbolProfiles.filter(({ scraperConfiguration }) => {
return scraperConfiguration?.mode === 'instant';
});
const scraperResultPromises =
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape(scraperConfiguration);
return { marketPrice, symbol };
} catch (error) {
Logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`,
'ManualService'
);
return { symbol, marketPrice: undefined };
}
}
);
// Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises);
for (const { currency, symbol } of symbolProfiles) { for (const { currency, symbol } of symbolProfiles) {
let marketPrice = let { marketPrice } =
scraperResults.find((result) => {
return result.symbol === symbol;
}) ?? {};
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => { marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol; return marketDataItem.symbol === symbol;
})?.marketPrice ?? 0; })?.marketPrice ??
0;
response[symbol] = { response[symbol] = {
currency, currency,

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

@ -20,6 +20,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart';
import {
HistoricalDividendsResult,
HistoricalHistoryResult
} from 'yahoo-finance2/dist/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote'; import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
@ -60,7 +65,8 @@ export class YahooFinanceService implements DataProviderInterface {
} }
try { try {
const historicalResult = await yahooFinance.historical( const historicalResult = this.convertToDividendResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol symbol
), ),
@ -70,8 +76,8 @@ export class YahooFinanceService implements DataProviderInterface {
period1: format(from, DATE_FORMAT), period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT) period2: format(to, DATE_FORMAT)
} }
)
); );
const response: { const response: {
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
} = {}; } = {};
@ -108,7 +114,8 @@ export class YahooFinanceService implements DataProviderInterface {
} }
try { try {
const historicalResult = await yahooFinance.historical( const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol symbol
), ),
@ -117,6 +124,7 @@ export class YahooFinanceService implements DataProviderInterface {
period1: format(from, DATE_FORMAT), period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT) period2: format(to, DATE_FORMAT)
} }
)
); );
const response: { const response: {
@ -302,6 +310,20 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private convertToDividendResult(
result: ChartResultArray
): HistoricalDividendsResult {
return result.events.dividends.map(({ amount: dividends, date }) => {
return { date, dividends };
});
}
private convertToHistoricalResult(
result: ChartResultArray
): HistoricalHistoryResult {
return result.quotes;
}
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => { return yahooFinance.quoteSummary(symbol).catch(() => {

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -275,6 +275,8 @@ export class SymbolProfileService {
headers: headers:
scraperConfiguration.headers as ScraperConfiguration['headers'], scraperConfiguration.headers as ScraperConfiguration['headers'],
locale: scraperConfiguration.locale as string, locale: scraperConfiguration.locale as string,
mode:
(scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy',
selector: scraperConfiguration.selector as string, selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string url: scraperConfiguration.url as string
}; };

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

@ -51,6 +51,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
'status', 'status',
'actions' 'actions'
]; ];
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User; public user: User;
@ -138,12 +139,16 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
} }
private fetchJobs(aStatus?: JobStatus[]) { private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService this.adminService
.fetchJobs({ status: aStatus }) .fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs); this.dataSource = new MatTableDataSource(jobs);
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

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

@ -183,6 +183,16 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
</div> </div>
</div> </div>

2
apps/client/src/app/components/admin-jobs/admin-jobs.module.ts

@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminJobsComponent } from './admin-jobs.component'; import { AdminJobsComponent } from './admin-jobs.component';
@ -17,6 +18,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -93,6 +93,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
}; };
}); });
if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity); let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = []; const missingMarketData: Partial<MarketData>[] = [];
@ -138,7 +139,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
}; };
} }
if (this.dateOfFirstActivity) {
// Fill up missing months // Fill up missing months
const dates = Object.keys(this.marketDataByMonth).sort(); const dates = Object.keys(this.marketDataByMonth).sort();
const startDate = min([ const startDate = min([

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

@ -5,7 +5,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -24,7 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> = public dataSource: MatTableDataSource<AdminUsers['users'][0]> =
new MatTableDataSource(); new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns: string[] = []; public displayedColumns: string[] = [];
@ -32,6 +32,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean; public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem; public info: InfoItem;
public isLoading = false;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -93,7 +94,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.fetchAdminData(); this.fetchUsers();
} }
public formatDistanceToNow(aDateString: string) { public formatDistanceToNow(aDateString: string) {
@ -118,7 +119,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId) .deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAdminData(); this.fetchUsers();
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
@ -141,13 +142,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchAdminData() { private fetchUsers() {
this.isLoading = true;
this.adminService this.adminService
.fetchAdminData() .fetchUsers()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => { .subscribe(({ users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource = new MatTableDataSource(users);
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

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

@ -245,6 +245,16 @@
></tr> ></tr>
</table> </table>
</div> </div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
</div> </div>
</div> </div>

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

@ -6,6 +6,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminUsersComponent } from './admin-users.component'; import { AdminUsersComponent } from './admin-users.component';
@ -18,7 +19,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatTableModule MatTableModule,
NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

2
apps/client/src/app/pages/landing/landing-page.html

@ -331,7 +331,7 @@
<div class="col-md-8 offset-md-2"> <div class="col-md-8 offset-md-2">
<gf-carousel [aria-label]="'Testimonials'"> <gf-carousel [aria-label]="'Testimonials'">
@for (testimonial of testimonials; track testimonial) { @for (testimonial of testimonials; track testimonial) {
<div gf-carousel-item> <div #carouselItem gf-carousel-item>
<div class="d-flex px-4"> <div class="d-flex px-4">
<gf-logo <gf-logo
class="mr-3 mt-2 pt-1" class="mr-3 mt-2 pt-1"

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

@ -108,8 +108,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.fetchActivities();
} }
public fetchActivities() { public fetchActivities() {

5
apps/client/src/app/services/admin.service.ts

@ -12,6 +12,7 @@ import {
AdminJobs, AdminJobs,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -155,6 +156,10 @@ export class AdminService {
return this.http.get<Tag[]>('/api/v1/tag'); return this.http.get<Tag[]>('/api/v1/tag');
} }
public fetchUsers() {
return this.http.get<AdminUsers>('/api/v1/admin/user');
}
public gather7Days() { public gather7Days() {
return this.http.post<void>('/api/v1/admin/gather', {}); return this.http.post<void>('/api/v1/admin/gather', {});
} }

22
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{ {
"createdAt": "2024-04-09T00:00:00.000Z", "createdAt": "2024-08-31T00:00:00.000Z",
"data": [ "data": [
{ {
"name": "Aptabase", "name": "Aptabase",
@ -46,11 +46,6 @@
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.", "description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
"href": "https://dyrector.io" "href": "https://dyrector.io"
}, },
{
"name": "Erxes",
"description": "The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
"href": "https://erxes.io"
},
{ {
"name": "Firecamp", "name": "Firecamp",
"description": "vscode for apis, open-source postman/insomnia alternative", "description": "vscode for apis, open-source postman/insomnia alternative",
@ -86,11 +81,6 @@
"description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.", "description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
"href": "https://infisical.com" "href": "https://infisical.com"
}, },
{
"name": "Keep",
"description": "Open source alert management and AIOps platform.",
"href": "https://keephq.dev"
},
{ {
"name": "Langfuse", "name": "Langfuse",
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.", "description": "Open source LLM engineering platform. Debug, analyze and iterate together.",
@ -116,6 +106,11 @@
"description": "Open-source monitoring platform with beautiful status pages", "description": "Open-source monitoring platform with beautiful status pages",
"href": "https://www.openstatus.dev" "href": "https://www.openstatus.dev"
}, },
{
"name": "Portkey AI",
"description": "AI Gateway with integrated Guardrails. Route to 250+ LLMs and 50+ Guardrails with 1-fast API. Supports caching, retries, and edge deployment for low latency.",
"href": "https://www.portkey.ai"
},
{ {
"name": "Prisma", "name": "Prisma",
"description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.", "description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.",
@ -126,11 +121,6 @@
"description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.", "description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
"href": "https://requestly.com" "href": "https://requestly.com"
}, },
{
"name": "Revert",
"description": "The open-source unified API to build B2B integrations remarkably fast",
"href": "https://revert.dev"
},
{ {
"name": "Rivet", "name": "Rivet",
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.", "description": "Open-source solution to deploy, scale, and operate your multiplayer game.",

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

File diff suppressed because it is too large

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

@ -30,6 +30,9 @@ export const warnColorRgb = {
b: 69 b: 69
}; };
export const CACHE_TTL_NO_CACHE = 1;
export const CACHE_TTL_INFINITE = 0;
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE'; export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1; export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER; export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER;

12
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -1,7 +1,5 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Role } from '@prisma/client';
export interface AdminData { export interface AdminData {
exchangeRates: ({ exchangeRates: ({
label1: string; label1: string;
@ -11,15 +9,5 @@ export interface AdminData {
settings: { [key: string]: boolean | object | string | string[] }; settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;
lastActivity: Date;
role: Role;
transactionCount: number;
}[];
version: string; version: string;
} }

14
libs/common/src/lib/interfaces/admin-users.interface.ts

@ -0,0 +1,14 @@
import { Role } from '@prisma/client';
export interface AdminUsers {
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;
lastActivity: Date;
role: Role;
transactionCount: number;
}[];
}

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

@ -7,6 +7,7 @@ import type {
AdminMarketData, AdminMarketData,
AdminMarketDataItem AdminMarketDataItem
} from './admin-market-data.interface'; } from './admin-market-data.interface';
import type { AdminUsers } from './admin-users.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface'; import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.interface'; import type { BenchmarkProperty } from './benchmark-property.interface';
@ -61,6 +62,7 @@ export {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,

1
libs/common/src/lib/interfaces/scraper-configuration.interface.ts

@ -2,6 +2,7 @@ export interface ScraperConfiguration {
defaultMarketPrice?: number; defaultMarketPrice?: number;
headers?: { [key: string]: string }; headers?: { [key: string]: string };
locale?: string; locale?: string;
mode?: 'instant' | 'lazy';
selector: string; selector: string;
url: string; url: string;
} }

12
libs/ui/src/lib/carousel/carousel-item.directive.ts

@ -1,16 +1,8 @@
import { FocusableOption } from '@angular/cdk/a11y'; import { Directive, ElementRef } from '@angular/core';
import { Directive, ElementRef, HostBinding } from '@angular/core';
@Directive({ @Directive({
selector: '[gf-carousel-item]' selector: '[gf-carousel-item]'
}) })
export class CarouselItem implements FocusableOption { export class CarouselItem {
@HostBinding('attr.role') readonly role = 'listitem';
@HostBinding('tabindex') tabindex = '-1';
public constructor(readonly element: ElementRef<HTMLElement>) {} public constructor(readonly element: ElementRef<HTMLElement>) {}
public focus() {
this.element.nativeElement.focus({ preventScroll: true });
}
} }

7
libs/ui/src/lib/carousel/carousel.component.html

@ -11,12 +11,7 @@
</button> </button>
} }
<div <div #contentWrapper class="overflow-hidden" role="region">
#contentWrapper
class="overflow-hidden"
role="region"
(keyup)="onKeydown($event)"
>
<div #list class="d-flex carousel-content" role="list" tabindex="0"> <div #list class="d-flex carousel-content" role="list" tabindex="0">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

64
libs/ui/src/lib/carousel/carousel.component.ts

@ -1,24 +1,18 @@
import { FocusKeyManager } from '@angular/cdk/a11y';
import { LEFT_ARROW, RIGHT_ARROW, TAB } from '@angular/cdk/keycodes';
import { import {
AfterContentInit,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
ContentChildren, contentChildren,
ElementRef, ElementRef,
HostBinding, HostBinding,
Inject, Inject,
Input, Input,
Optional, Optional,
QueryList,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations';
import { CarouselItem } from './carousel-item.directive';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule], imports: [MatButtonModule],
@ -28,9 +22,7 @@ import { CarouselItem } from './carousel-item.directive';
styleUrls: ['./carousel.component.scss'], styleUrls: ['./carousel.component.scss'],
templateUrl: './carousel.component.html' templateUrl: './carousel.component.html'
}) })
export class GfCarouselComponent implements AfterContentInit { export class GfCarouselComponent {
@ContentChildren(CarouselItem) public items!: QueryList<CarouselItem>;
@HostBinding('class.animations-disabled') @HostBinding('class.animations-disabled')
public readonly animationsDisabled: boolean; public readonly animationsDisabled: boolean;
@ -38,11 +30,11 @@ export class GfCarouselComponent implements AfterContentInit {
@ViewChild('list') public list!: ElementRef<HTMLElement>; @ViewChild('list') public list!: ElementRef<HTMLElement>;
public items = contentChildren('carouselItem', { read: ElementRef });
public showPrevArrow = false; public showPrevArrow = false;
public showNextArrow = true; public showNextArrow = true;
private index = 0; private index = 0;
private keyManager!: FocusKeyManager<CarouselItem>;
private position = 0; private position = 0;
public constructor( public constructor(
@ -51,12 +43,8 @@ export class GfCarouselComponent implements AfterContentInit {
this.animationsDisabled = animationsModule === 'NoopAnimations'; this.animationsDisabled = animationsModule === 'NoopAnimations';
} }
public ngAfterContentInit() {
this.keyManager = new FocusKeyManager<CarouselItem>(this.items);
}
public next() { public next() {
for (let i = this.index; i < this.items.length; i++) { for (let i = this.index; i < this.items().length; i++) {
if (this.isOutOfView(i)) { if (this.isOutOfView(i)) {
this.index = i; this.index = i;
this.scrollToActiveItem(); this.scrollToActiveItem();
@ -65,31 +53,6 @@ export class GfCarouselComponent implements AfterContentInit {
} }
} }
public onKeydown({ keyCode }: KeyboardEvent) {
const manager = this.keyManager;
const previousActiveIndex = manager.activeItemIndex;
if (keyCode === LEFT_ARROW) {
manager.setPreviousItemActive();
} else if (keyCode === RIGHT_ARROW) {
manager.setNextItemActive();
} else if (keyCode === TAB && !manager.activeItem) {
manager.setFirstItemActive();
}
if (
manager.activeItemIndex != null &&
manager.activeItemIndex !== previousActiveIndex
) {
this.index = manager.activeItemIndex;
this.updateItemTabIndices();
if (this.isOutOfView(this.index)) {
this.scrollToActiveItem();
}
}
}
public previous() { public previous() {
for (let i = this.index; i > -1; i--) { for (let i = this.index; i > -1; i--) {
if (this.isOutOfView(i)) { if (this.isOutOfView(i)) {
@ -101,8 +64,7 @@ export class GfCarouselComponent implements AfterContentInit {
} }
private isOutOfView(index: number, side?: 'start' | 'end') { private isOutOfView(index: number, side?: 'start' | 'end') {
const { offsetWidth, offsetLeft } = const { offsetWidth, offsetLeft } = this.items()[index].nativeElement;
this.items.toArray()[index].element.nativeElement;
if ((!side || side === 'start') && offsetLeft - this.position < 0) { if ((!side || side === 'start') && offsetLeft - this.position < 0) {
return true; return true;
@ -120,33 +82,23 @@ export class GfCarouselComponent implements AfterContentInit {
return; return;
} }
const itemsArray = this.items.toArray();
let targetItemIndex = this.index; let targetItemIndex = this.index;
if (this.index > 0 && !this.isOutOfView(this.index - 1)) { if (this.index > 0 && !this.isOutOfView(this.index - 1)) {
targetItemIndex = targetItemIndex =
itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1; this.items().findIndex((_, i) => !this.isOutOfView(i)) + 1;
} }
this.position = this.position = this.items()[targetItemIndex].nativeElement.offsetLeft;
itemsArray[targetItemIndex].element.nativeElement.offsetLeft;
this.list.nativeElement.style.transform = `translateX(-${this.position}px)`; this.list.nativeElement.style.transform = `translateX(-${this.position}px)`;
this.showPrevArrow = this.index > 0; this.showPrevArrow = this.index > 0;
this.showNextArrow = false; this.showNextArrow = false;
for (let i = itemsArray.length - 1; i > -1; i--) { for (let i = this.items().length - 1; i > -1; i--) {
if (this.isOutOfView(i, 'end')) { if (this.isOutOfView(i, 'end')) {
this.showNextArrow = true; this.showNextArrow = true;
break; break;
} }
} }
} }
private updateItemTabIndices() {
this.items.forEach((item: CarouselItem) => {
if (this.keyManager != null) {
item.tabindex = item === this.keyManager.activeItem ? '0' : '-1';
}
});
}
} }

2
package.json

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

Loading…
Cancel
Save