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. 72
      apps/api/src/app/admin/admin.service.ts
  4. 7
      apps/api/src/app/benchmark/benchmark.service.ts
  5. 33
      apps/api/src/app/info/info.service.ts
  6. 3
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 65
      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. 62
      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. 76
      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
- 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 Italian (`it`)
## 2.106.0-beta.5 - 2024-08-31
## 2.106.0 - 2024-09-07
### Added
- 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
@ -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_TTL` from seconds to milliseconds
- 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`)
- Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet`
- 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 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

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

@ -17,6 +17,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -239,9 +240,11 @@ export class AdminController {
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) {
Logger.error(error);
Logger.error(error, 'AdminController');
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
@ -345,4 +348,11 @@ export class AdminController {
) {
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();
}
}

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

@ -21,6 +21,7 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -107,35 +108,42 @@ export class AdminService {
}
public async get(): Promise<AdminData> {
return {
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
const exchangeRates = this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
}),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics(),
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
});
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.prismaService.user.count()
]);
return {
exchangeRates,
settings,
transactionCount,
userCount,
version: environment.version
};
}
@ -377,6 +385,10 @@ export class AdminService {
};
}
public async getUsers(): Promise<AdminUsers> {
return { users: await this.getUsersWithAnalytics() };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
@ -546,11 +558,11 @@ export class AdminService {
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = {
createdAt: 'desc'
};
let where;
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
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 { PropertyService } from '@ghostfolio/api/services/property/property.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 {
DATE_FORMAT,
calculateBenchmarkTrend,
@ -443,7 +446,7 @@ export class BenchmarkService {
benchmarks,
expiration: expiration.getTime()
}),
0
CACHE_TTL_INFINITE
);
}

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

@ -54,9 +54,6 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
const globalPermissions: string[] = [];
@ -100,22 +97,30 @@ export class InfoService {
globalPermissions.push(permissions.enableSystemMessage);
}
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
const [
benchmarks,
demoAuthToken,
isUserSignupEnabled,
platforms,
statistics,
subscriptions,
tags
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
return {
...info,
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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { CACHE_TTL_INFINITE } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getSum,
@ -882,7 +883,7 @@ export abstract class PortfolioCalculator {
expiration: expiration.getTime(),
portfolioSnapshot: snapshot
})),
0
CACHE_TTL_INFINITE
);
return snapshot;

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

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

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

@ -1,5 +1,8 @@
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 { DataSource } from '@prisma/client';
@ -22,7 +25,7 @@ export class ConfigurationService {
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
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_IMPORT: str({ default: DataSource.YAHOO }),
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) {
let marketPrice =
let { marketPrice } =
scraperResults.find((result) => {
return result.symbol === symbol;
}) ?? {};
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.marketPrice ?? 0;
})?.marketPrice ??
0;
response[symbol] = {
currency,

62
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 { addDays, format, isSameDay } from 'date-fns';
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';
@Injectable()
@ -60,18 +65,19 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
const historicalResult = this.convertToDividendResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
@ -108,15 +114,17 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
@ -302,6 +310,20 @@ export class YahooFinanceService implements DataProviderInterface {
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[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => {

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

@ -275,6 +275,8 @@ export class SymbolProfileService {
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
locale: scraperConfiguration.locale as string,
mode:
(scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy',
selector: scraperConfiguration.selector 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',
'actions'
];
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
@ -138,12 +139,16 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
}
private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.isLoading = false;
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 *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</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 { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminJobsComponent } from './admin-jobs.component';
@ -17,6 +18,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatMenuModule,
MatSelectModule,
MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -93,52 +93,52 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
};
});
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
}
}
}
const marketDataItems = [...missingMarketData, ...this.marketData];
const marketDataItems = [...missingMarketData, ...this.marketData];
if (!isToday(last(marketDataItems)?.date)) {
marketDataItems.push({ date: new Date() });
}
if (!isToday(last(marketDataItems)?.date)) {
marketDataItems.push({ date: new Date() });
}
this.marketDataByMonth = {};
this.marketDataByMonth = {};
for (const marketDataItem of marketDataItems) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
for (const marketDataItem of marketDataItems) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
date: marketDataItem.date,
day: currentDay,
marketPrice: marketDataItem.marketPrice
};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
date: marketDataItem.date,
day: currentDay,
marketPrice: marketDataItem.marketPrice
};
}
if (this.dateOfFirstActivity) {
// Fill up missing months
const dates = Object.keys(this.marketDataByMonth).sort();
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 { UserService } from '@ghostfolio/client/services/user/user.service';
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 { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -24,7 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
public dataSource: MatTableDataSource<AdminUsers['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
@ -32,6 +32,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public isLoading = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -93,7 +94,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
this.fetchAdminData();
this.fetchUsers();
}
public formatDistanceToNow(aDateString: string) {
@ -118,7 +119,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAdminData();
this.fetchUsers();
});
},
confirmType: ConfirmationDialogType.Warn,
@ -141,13 +142,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
private fetchUsers() {
this.isLoading = true;
this.adminService
.fetchAdminData()
.fetchUsers()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.dataSource = new MatTableDataSource(users);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

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

@ -245,6 +245,16 @@
></tr>
</table>
</div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</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 { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminUsersComponent } from './admin-users.component';
@ -18,7 +19,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent,
MatButtonModule,
MatMenuModule,
MatTableModule
MatTableModule,
NgxSkeletonLoaderModule
],
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">
<gf-carousel [aria-label]="'Testimonials'">
@for (testimonial of testimonials; track testimonial) {
<div gf-carousel-item>
<div #carouselItem gf-carousel-item>
<div class="d-flex px-4">
<gf-logo
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.fetchActivities();
}
public fetchActivities() {

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

@ -12,6 +12,7 @@ import {
AdminJobs,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
@ -155,6 +156,10 @@ export class AdminService {
return this.http.get<Tag[]>('/api/v1/tag');
}
public fetchUsers() {
return this.http.get<AdminUsers>('/api/v1/admin/user');
}
public gather7Days() {
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": [
{
"name": "Aptabase",
@ -46,11 +46,6 @@
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
"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",
"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.",
"href": "https://infisical.com"
},
{
"name": "Keep",
"description": "Open source alert management and AIOps platform.",
"href": "https://keephq.dev"
},
{
"name": "Langfuse",
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.",
@ -116,6 +106,11 @@
"description": "Open-source monitoring platform with beautiful status pages",
"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",
"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.",
"href": "https://requestly.com"
},
{
"name": "Revert",
"description": "The open-source unified API to build B2B integrations remarkably fast",
"href": "https://revert.dev"
},
{
"name": "Rivet",
"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
};
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_PRIORITY_HIGH = 1;
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 { Role } from '@prisma/client';
export interface AdminData {
exchangeRates: ({
label1: string;
@ -11,15 +9,5 @@ export interface AdminData {
settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number;
userCount: number;
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;
lastActivity: Date;
role: Role;
transactionCount: number;
}[];
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,
AdminMarketDataItem
} from './admin-market-data.interface';
import type { AdminUsers } from './admin-users.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.interface';
@ -61,6 +62,7 @@ export {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,

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

@ -2,6 +2,7 @@ export interface ScraperConfiguration {
defaultMarketPrice?: number;
headers?: { [key: string]: string };
locale?: string;
mode?: 'instant' | 'lazy';
selector: 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, HostBinding } from '@angular/core';
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[gf-carousel-item]'
})
export class CarouselItem implements FocusableOption {
@HostBinding('attr.role') readonly role = 'listitem';
@HostBinding('tabindex') tabindex = '-1';
export class CarouselItem {
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>
}
<div
#contentWrapper
class="overflow-hidden"
role="region"
(keyup)="onKeydown($event)"
>
<div #contentWrapper class="overflow-hidden" role="region">
<div #list class="d-flex carousel-content" role="list" tabindex="0">
<ng-content></ng-content>
</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 {
AfterContentInit,
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
ContentChildren,
contentChildren,
ElementRef,
HostBinding,
Inject,
Input,
Optional,
QueryList,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations';
import { CarouselItem } from './carousel-item.directive';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule],
@ -28,9 +22,7 @@ import { CarouselItem } from './carousel-item.directive';
styleUrls: ['./carousel.component.scss'],
templateUrl: './carousel.component.html'
})
export class GfCarouselComponent implements AfterContentInit {
@ContentChildren(CarouselItem) public items!: QueryList<CarouselItem>;
export class GfCarouselComponent {
@HostBinding('class.animations-disabled')
public readonly animationsDisabled: boolean;
@ -38,11 +30,11 @@ export class GfCarouselComponent implements AfterContentInit {
@ViewChild('list') public list!: ElementRef<HTMLElement>;
public items = contentChildren('carouselItem', { read: ElementRef });
public showPrevArrow = false;
public showNextArrow = true;
private index = 0;
private keyManager!: FocusKeyManager<CarouselItem>;
private position = 0;
public constructor(
@ -51,12 +43,8 @@ export class GfCarouselComponent implements AfterContentInit {
this.animationsDisabled = animationsModule === 'NoopAnimations';
}
public ngAfterContentInit() {
this.keyManager = new FocusKeyManager<CarouselItem>(this.items);
}
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)) {
this.index = i;
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() {
for (let i = this.index; i > -1; i--) {
if (this.isOutOfView(i)) {
@ -101,8 +64,7 @@ export class GfCarouselComponent implements AfterContentInit {
}
private isOutOfView(index: number, side?: 'start' | 'end') {
const { offsetWidth, offsetLeft } =
this.items.toArray()[index].element.nativeElement;
const { offsetWidth, offsetLeft } = this.items()[index].nativeElement;
if ((!side || side === 'start') && offsetLeft - this.position < 0) {
return true;
@ -120,33 +82,23 @@ export class GfCarouselComponent implements AfterContentInit {
return;
}
const itemsArray = this.items.toArray();
let targetItemIndex = this.index;
if (this.index > 0 && !this.isOutOfView(this.index - 1)) {
targetItemIndex =
itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1;
this.items().findIndex((_, i) => !this.isOutOfView(i)) + 1;
}
this.position =
itemsArray[targetItemIndex].element.nativeElement.offsetLeft;
this.position = this.items()[targetItemIndex].nativeElement.offsetLeft;
this.list.nativeElement.style.transform = `translateX(-${this.position}px)`;
this.showPrevArrow = this.index > 0;
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')) {
this.showNextArrow = true;
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",
"version": "2.106.0-beta.5",
"version": "2.106.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save