Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror

This commit is contained in:
ksyasuda 2024-09-24 00:15:23 -07:00
commit 1ed71ed7ec
86 changed files with 3992 additions and 2268 deletions

View File

@ -7,13 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Considered the users language in the link of the access table to share the portfolio
- Improved the language localization for German (`de`)
## 2.109.0 - 2024-09-17
### Added
- Extended the _Public API_ with a new endpoint that provides portfolio performance metrics (experimental)
- Added the portfolio performance metrics to the public page
- Added a blog post: _Hacktoberfest 2024_
### Changed
- Improved the usability of the create or update access dialog
- Improved the loading indicator of the accounts table
- Exposed the concurrency of the asset profile data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE`)
- Exposed the concurrency of the historical market data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA`)
- Exposed the concurrency of the portfolio snapshot calculation as an environment variable (`PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `prisma` from version `5.19.0` to `5.19.1`
## 2.108.0 - 2024-09-17
### Added
- Added support for bonds in the import dividends dialog
- Added a _Copy link to clipboard_ action to the access table to share the portfolio
- Added the current market price column to the historical market data table of the admin control
- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
### Changed
- Improved the usability of the toggle component
- Switched to the accounts endpoint in the holding detail dialog
- Added a fallback in the get quotes functionality of the _EOD Historical Data_ service
## 2.107.1 - 2024-09-12

View File

@ -165,6 +165,10 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
### Import Activities
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization
#### Request
`POST http://localhost:3333/api/v1/import`
@ -220,6 +224,38 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
### Portfolio (experimental)
#### Prerequisites
Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
#### Request
`GET http://localhost:3333/api/v1/public/<INSERT_ACCESS_ID>/portfolio`
**Info:** No Bearer Token is required for authorization
#### Response
##### Success
```
{
"performance": {
"1d": {
"relativeChange": 0 // normalized from -1 to 1
};
"ytd": {
"relativeChange": 0 // normalized from -1 to 1
},
"max": {
"relativeChange": 0 // normalized from -1 to 1
}
}
}
```
## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio

View File

@ -15,7 +15,11 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -261,6 +265,37 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
@ -281,6 +316,11 @@ export class AdminService {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
@ -288,6 +328,7 @@ export class AdminService {
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
@ -298,6 +339,7 @@ export class AdminService {
countriesCount,
dataSource,
id,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
@ -511,48 +553,86 @@ export class AdminService {
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const lastMarketPriceMap = new Map<string, number>();
return {
activitiesCount,
currency,
dataSource,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
});
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };

View File

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -85,6 +86,7 @@ import { UserModule } from './user/user.module';
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
PublicModule,
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({

View File

@ -0,0 +1,134 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('public')
export class PublicController {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get(':accessId/portfolio')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });
if (!access) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const [
{ holdings },
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id,
withMarkets: true
}),
...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({
dateRange,
impersonationId: undefined,
userId: user.id
});
})
]);
const publicPortfolioResponse: PublicPortfolioResponse = {
hasDetails,
alias: access.alias,
holdings: {},
performance: {
'1d': {
relativeChange:
performance1d.netPerformancePercentageWithCurrencyEffect
},
max: {
relativeChange:
performanceMax.netPerformancePercentageWithCurrencyEffect
},
ytd: {
relativeChange:
performanceYtd.netPerformancePercentageWithCurrencyEffect
}
}
};
const totalValue = getSum(
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
currency,
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
)
);
})
).toNumber();
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}
return publicPortfolioResponse;
}
}

View File

@ -0,0 +1,49 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { PublicController } from './public.controller';
@Module({
controllers: [PublicController],
imports: [
AccessModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class PublicModule {}

View File

@ -1,6 +1,5 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
@ -13,20 +12,15 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
@ -70,12 +64,10 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('details')
@ -497,75 +489,6 @@ export class PortfolioController {
return performanceInformation;
}
@Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
if (!access) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails({
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id,
withMarkets: true
});
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
alias: access.alias,
holdings: {}
};
const totalValue = Object.values(holdings)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}
return portfolioPublicDetails;
}
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)

View File

@ -371,7 +371,7 @@ export class UserService {
const hashedAccessToken = this.createAccessToken(
accessToken,
process.env.ACCESS_TOKEN_SALT
this.configurationService.get('ACCESS_TOKEN_SALT')
);
user = await this.prismaService.user.update({

View File

@ -172,6 +172,10 @@
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -446,12 +450,46 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/cennik</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl</loc>
<loc>https://ghostfol.io/pl/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/funkcje</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/o-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/rynki</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/zarejestruj</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -83,6 +83,10 @@ const locales = {
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
},
'/en/blog/2024/09/hacktoberfest-2024': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
title: `Hacktoberfest 2024 - ${title}`
}
};

View File

@ -1,6 +1,9 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import {
CACHE_TTL_NO_CACHE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config';
@ -47,6 +50,15 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }),
PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE
}),
PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA
}),
PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT
}),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),

View File

@ -18,6 +18,7 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -229,7 +230,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
).json<any>();
const quotes =
const quotes: {
close: number;
code: string;
previousClose: number;
timestamp: number;
}[] =
eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
@ -243,7 +249,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})
);
for (const { close, code, timestamp } of quotes) {
for (const { close, code, previousClose, timestamp } of quotes) {
let currency: string;
if (this.isForex(code)) {
@ -267,15 +273,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
}
if (isNumber(close)) {
if (isNumber(close) || isNumber(previousClose)) {
const marketPrice: number = isNumber(close) ? close : previousClose;
let marketState: MarketState = 'closed';
if (this.isForex(code) || isToday(new Date(timestamp * 1000))) {
marketState = 'open';
} else if (!isNumber(close)) {
marketState = 'delayed';
}
response[this.convertFromEodSymbol(code)] = {
currency,
dataSource: this.getName(),
marketPrice: close,
marketState:
this.isForex(code) || isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
marketPrice,
marketState,
dataSource: this.getName()
};
} else {
Logger.error(

View File

@ -3,6 +3,8 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config';
@ -34,7 +36,14 @@ export class DataGatheringProcessor {
private readonly marketDataService: MarketDataService
) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(),
10
),
name: GATHER_ASSET_PROFILE_PROCESS
})
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try {
Logger.log(
@ -59,7 +68,11 @@ export class DataGatheringProcessor {
}
@Process({
concurrency: 1,
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(),
10
),
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {

View File

@ -8,6 +8,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
CACHE_TTL_INFINITE,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_QUEUE
} from '@ghostfolio/common/config';
@ -29,7 +30,14 @@ export class PortfolioSnapshotProcessor {
private readonly redisCacheService: RedisCacheService
) {}
@Process({ concurrency: 1, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME })
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ??
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(),
10
),
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME
})
public async calculatePortfolioSnapshot(
job: Job<IPortfolioSnapshotQueueJob>
) {

View File

@ -168,11 +168,9 @@
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<!--
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
-->
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>

View File

@ -57,19 +57,25 @@ export class AppComponent implements OnDestroy, OnInit {
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`about`,
$localize`privacy-policy`
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkAboutChangelog = [
'/' + $localize`:snake-case:about`,
'changelog'
];
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkAboutLicense = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:license`
];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:privacy-policy`
];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
public showFooter = false;
public user: User;

View File

@ -35,12 +35,19 @@
@if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" />
<a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
>
<a target="_blank" [href]="getPublicUrl(element.id)">{{
getPublicUrl(element.id)
}}</a>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
}
</td>
</ng-container>
@ -58,6 +65,11 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
@if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyToClipboard(element.id)">
<ng-container i18n>Copy link to clipboard</ng-container>
</button>
}
<button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container>
</button>

View File

@ -1,8 +1,9 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces';
import { Access, User } from '@ghostfolio/common/interfaces';
import { Clipboard } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
@ -23,15 +24,18 @@ import { MatTableDataSource } from '@angular/material/table';
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public displayedColumns = [];
public constructor(private notificationService: NotificationService) {}
public constructor(
private clipboard: Clipboard,
private notificationService: NotificationService
) {}
public ngOnInit() {}
@ -47,6 +51,16 @@ export class AccessTableComponent implements OnChanges, OnInit {
}
}
public getPublicUrl(aId: string): string {
const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
return `${this.baseUrl}/${languageCode}/p/${aId}`;
}
public onCopyToClipboard(aId: string): void {
this.clipboard.copy(this.getPublicUrl(aId));
}
public onDeleteAccess(aId: string) {
this.notificationService.confirm({
confirmFn: () => {

View File

@ -1,3 +1,4 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@ -11,6 +12,7 @@ import { AccessTableComponent } from './access-table.component';
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [
ClipboardModule,
CommonModule,
MatButtonModule,
MatMenuModule,

View File

@ -92,11 +92,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = true;
if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
if (this.accounts) {
this.isLoading = false;
}
}

View File

@ -142,6 +142,7 @@ export class AdminMarketDataComponent
'dataSource',
'assetClass',
'assetSubClass',
'lastMarketPrice',
'date',
'activitiesCount',
'marketDataItemCount',

View File

@ -99,6 +99,21 @@
</td>
</ng-container>
<ng-container matColumnDef="lastMarketPrice">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Market Price</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[value]="element.lastMarketPrice ?? ''"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>

View File

@ -1,6 +1,7 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -27,6 +28,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfCreateAssetProfileDialogModule,
GfPremiumIndicatorComponent,
GfSymbolModule,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,

View File

@ -75,17 +75,17 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
public isMenuOpen: boolean;
public routeAbout = $localize`about`;
public routeFeatures = $localize`features`;
public routeMarkets = $localize`markets`;
public routePricing = $localize`pricing`;
public routeResources = $localize`resources`;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public routeAbout = $localize`:snake-case:about`;
public routeFeatures = $localize`:snake-case:features`;
public routeMarkets = $localize`:snake-case:markets`;
public routePricing = $localize`:snake-case:pricing`;
public routeResources = $localize`:snake-case:resources`;
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
private unsubscribeSubject = new Subject<void>();

View File

@ -11,7 +11,7 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
templateUrl: 'subscription-interstitial-dialog.html'
})
export class SubscriptionInterstitialDialog {
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,

View File

@ -7,7 +7,10 @@
<mat-radio-button
class="d-inline-flex"
[disabled]="isLoading"
[ngClass]="{ 'cursor-pointer': !isLoading }"
[ngClass]="{
'cursor-default': option.value === optionFormControl.value,
'cursor-pointer': !isLoading && option.value !== optionFormControl.value
}"
[value]="option.value"
>{{ option.label }}</mat-radio-button
>

View File

@ -52,13 +52,12 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required);
permissionsControl.setValidators(Validators.required);
} else {
granteeUserIdControl.clearValidators();
permissionsControl.setValue(this.data.access.permissions[0]);
}
granteeUserIdControl.updateValueAndValidity();
permissionsControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck();
});

View File

@ -27,18 +27,18 @@
</mat-form-field>
</div>
@if (accessForm.get('type').value === 'PRIVATE') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED"
>Restricted view</mat-option
>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if (accessForm.get('type').value === 'PRIVATE') {
<mat-option i18n value="READ">View</mat-option>
</mat-select>
</mat-form-field>
</div>
}
</mat-select>
</mat-form-field>
</div>
@if (accessForm.get('type').value === 'PRIVATE') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label>

View File

@ -111,7 +111,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
type: 'PRIVATE'
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

View File

@ -10,6 +10,7 @@
<gf-access-table
[accesses]="accesses"
[showActions]="hasPermissionToDeleteAccess"
[user]="user"
(accessDeleted)="onDeleteAccess($event)"
/>
@if (hasPermissionToCreateAccess) {

View File

@ -36,7 +36,7 @@ export class UserAccountMembershipComponent implements OnDestroy, OnInit {
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';

View File

@ -56,6 +56,10 @@
}
</div>
</div>
} @else {
<div class="mt-3 text-muted">
<small i18n>No auto-renewal.</small>
</div>
}
</div>
</div>

View File

@ -16,8 +16,8 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public user: User;
private unsubscribeSubject = new Subject<void>();

View File

@ -136,18 +136,18 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}
public onDeleteAccount(aId: string) {
this.reset();
this.dataService
.deleteAccount(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
this.fetchAccounts();
});
}
@ -193,19 +193,21 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((account: UpdateAccountDto | null) => {
if (account) {
this.reset();
this.dataService
.putAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
this.fetchAccounts();
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
@ -264,19 +266,21 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((account: CreateAccountDto | null) => {
if (account) {
this.reset();
this.dataService
.postAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchAccounts();
}
this.fetchAccounts();
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
@ -296,6 +300,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
if (data) {
this.reset();
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
data?.account;
@ -318,9 +324,18 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.subscribe(() => {
this.fetchAccounts();
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private reset() {
this.accounts = undefined;
this.totalBalanceInBaseCurrency = 0;
this.totalValueInBaseCurrency = 0;
this.transactionCount = 0;
}
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './hallo-ghostfolio-page.html'
})
export class HalloGhostfolioPageComponent {
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './hello-ghostfolio-page.html'
})
export class HelloGhostfolioPageComponent {
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
}

View File

@ -10,5 +10,5 @@ import { RouterModule } from '@angular/router';
templateUrl: './first-months-in-open-source-page.html'
})
export class FirstMonthsInOpenSourcePageComponent {
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -10,5 +10,5 @@ import { RouterModule } from '@angular/router';
templateUrl: './how-do-i-get-my-finances-in-order-page.html'
})
export class HowDoIGetMyFinancesInOrderPageComponent {
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './500-stars-on-github-page.html'
})
export class FiveHundredStarsOnGitHubPageComponent {
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -12,6 +12,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './black-friday-2022-page.html'
})
export class BlackFriday2022PageComponent {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './1000-stars-on-github-page.html'
})
export class ThousandStarsOnGitHubPageComponent {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './unlock-your-financial-potential-with-ghostfolio-page.html'
})
export class UnlockYourFinancialPotentialWithGhostfolioPageComponent {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
}

View File

@ -10,5 +10,5 @@ import { RouterModule } from '@angular/router';
templateUrl: './exploring-the-path-to-fire-page.html'
})
export class ExploringThePathToFirePageComponent {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
}

View File

@ -10,5 +10,8 @@ import { RouterModule } from '@angular/router';
templateUrl: './ghostfolio-joins-oss-friends-page.html'
})
export class GhostfolioJoinsOssFriendsPageComponent {
public routerLinkAboutOssFriends = ['/' + $localize`about`, 'oss-friends'];
public routerLinkAboutOssFriends = [
'/' + $localize`:snake-case:about`,
'oss-friends'
];
}

View File

@ -10,8 +10,11 @@ import { RouterModule } from '@angular/router';
templateUrl: './ghostfolio-2-page.html'
})
export class Ghostfolio2PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkAboutChangelog = [
'/' + $localize`:snake-case:about`,
'changelog'
];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
}

View File

@ -10,5 +10,5 @@ import { RouterModule } from '@angular/router';
templateUrl: './hacktoberfest-2023-page.html'
})
export class Hacktoberfest2023PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
}

View File

@ -12,6 +12,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './black-week-2023-page.html'
})
export class BlackWeek2023PageComponent {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -10,6 +10,6 @@ import { RouterModule } from '@angular/router';
templateUrl: './hacktoberfest-2023-debriefing-page.html'
})
export class Hacktoberfest2023DebriefingPageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
}

View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2024-page',
standalone: true,
templateUrl: './hacktoberfest-2024-page.html'
})
export class Hacktoberfest2024PageComponent {
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
}

View File

@ -0,0 +1,200 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hacktoberfest 2024</h1>
<div class="mb-3 text-muted"><small>2024-09-21</small></div>
<img
alt="Hacktoberfest 2024 with Ghostfolio Teaser"
class="rounded w-100"
src="../assets/images/blog/hacktoberfest-2024.png"
title="Hacktoberfest 2024 with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
At Ghostfolio, <a [routerLink]="routerLinkAbout">we</a> are very
excited to join
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the
<a href="../en/blog/2023/11/hacktoberfest-2023-debriefing"
>third time</a
>
and look forward to connecting with new, enthusiastic open-source
contributors. Hacktoberfest is a a month-long celebration of all
things open-source: projects, their maintainers, and the entire
community of contributors. Every October, open source maintainers
around the globe dedicate extra time to support new contributors
while guiding them through their first pull requests on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Introducing Ghostfolio: Personal Finance Dashboard</h2>
<p>
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web
application for managing personal finances. It aggregates your
assets and helps you make informed decisions to balance your
portfolio or plan future investments.
</p>
<p>
The software is fully written in
<a href="https://www.typescriptlang.org">TypeScript</a> and
organized as an <a href="https://nx.dev">Nx</a> workspace, utilizing
the latest framework releases. The backend is based on
<a href="https://nestjs.com">NestJS</a> in combination with
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
together with <a href="https://www.prisma.io">Prisma</a> and
<a href="https://redis.io">Redis</a> for caching. The frontend is
built with <a href="https://angular.dev">Angular</a>.
</p>
<p>
The OSS project counting more than 100 contributors is used daily by
its growing global community. With over
<a [routerLink]="['/open']">4000 stars on GitHub</a> and
<a [routerLink]="['/open']">800000+ pulls on Docker Hub</a>,
Ghostfolio has gained widespread recognition for its user-friendly
experience and simplicity.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How you can make an impact</h2>
<p>
Every contribution can make a difference. Whether it involves
implementing new features, resolving bugs, refactoring code,
enhancing documentation, adding unit tests, or translating content
into another language, you can actively shape our project.
</p>
<p>
Not familiar with our codebase yet? No problem! We have labeled a
few
<a
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
>issues</a
>
with <code>hacktoberfest</code> that are well suited for newcomers.
</p>
<p>
The official Hacktoberfest website provides some valuable
<a
href="https://hacktoberfest.com/participation/#beginner-resources"
>resources for beginners</a
>
to start contributing in open source.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Connect with us</h2>
<p>
If you have further questions or ideas, please join our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community or get in touch on X
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
We look forward to collaborating.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Docker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">GitHub</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest 2024</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">NestJS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">October</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">TypeScript</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">UX</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2024
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -182,6 +182,15 @@ const routes: Routes = [
(c) => c.BlackWeek2023PageComponent
),
title: 'Black Week 2023'
},
{
canActivate: [AuthGuard],
path: '2024/09/hacktoberfest-2024',
loadComponent: () =>
import(
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component'
).then((c) => c.Hacktoberfest2024PageComponent),
title: 'Hacktoberfest 2024'
}
];

View File

@ -8,6 +8,30 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden p-3 w-100"
href="../en/blog/2024/09/hacktoberfest-2024"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Hacktoberfest 2024</div>
<div class="d-flex text-muted">2024-09-21</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
@if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">

View File

@ -11,7 +11,7 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './faq-overview-page.html'
})
export class FaqOverviewPageComponent implements OnDestroy {
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public user: User;
private unsubscribeSubject = new Subject<void>();

View File

@ -11,9 +11,9 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './saas-page.html'
})
export class SaasPageComponent implements OnDestroy {
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public user: User;
private unsubscribeSubject = new Subject<void>();

View File

@ -26,8 +26,8 @@ import { Subject, takeUntil } from 'rxjs';
export class GfFeaturesPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
public user: User;
private unsubscribeSubject = new Subject<void>();

View File

@ -23,8 +23,8 @@ export class LandingPageComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateUser: boolean;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public statistics: Statistics;
public testimonials = [
{

View File

@ -93,6 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
{
id: AssetClass.EQUITY,
type: 'ASSET_CLASS'
},
{
id: AssetClass.FIXED_INCOME,
type: 'ASSET_CLASS'
}
],
range: 'max'

View File

@ -33,8 +33,8 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public isLoggedIn: boolean;
public price: number;
public priceId: string;
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public user: User;
private unsubscribeSubject = new Subject<void>();

View File

@ -3,7 +3,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
PortfolioPosition,
PortfolioPublicDetails
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types';
@ -29,16 +29,16 @@ export class PublicPageComponent implements OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public holdings: PortfolioPublicDetails['holdings'][string][];
public holdings: PublicPortfolioResponse['holdings'][string][];
public markets: {
[key in Market]: { name: string; value: number };
};
public portfolioPublicDetails: PortfolioPublicDetails;
public positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
value: number;
};
};
public publicPortfolioDetails: PublicPortfolioResponse;
public sectors: {
[name: string]: { name: string; value: number };
};
@ -47,7 +47,7 @@ export class PublicPageComponent implements OnInit {
};
public UNKNOWN_KEY = UNKNOWN_KEY;
private id: string;
private accessId: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -58,7 +58,7 @@ export class PublicPageComponent implements OnInit {
private router: Router
) {
this.activatedRoute.params.subscribe((params) => {
this.id = params['id'];
this.accessId = params['id'];
});
}
@ -66,7 +66,7 @@ export class PublicPageComponent implements OnInit {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService
.fetchPortfolioPublic(this.id)
.fetchPublicPortfolio(this.accessId)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError((error) => {
@ -79,7 +79,7 @@ export class PublicPageComponent implements OnInit {
})
)
.subscribe((portfolioPublicDetails) => {
this.portfolioPublicDetails = portfolioPublicDetails;
this.publicPortfolioDetails = portfolioPublicDetails;
this.initializeAnalysisData();
@ -135,7 +135,7 @@ export class PublicPageComponent implements OnInit {
};
for (const [symbol, position] of Object.entries(
this.portfolioPublicDetails.holdings
this.publicPortfolioDetails.holdings
)) {
this.holdings.push(position);
@ -164,7 +164,7 @@ export class PublicPageComponent implements OnInit {
name: continent,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
@ -175,19 +175,19 @@ export class PublicPageComponent implements OnInit {
name,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.countries[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
this.markets[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
}
if (position.sectors.length > 0) {
@ -201,13 +201,13 @@ export class PublicPageComponent implements OnInit {
name,
value:
weight *
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].valueInBaseCurrency;
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
}
this.symbols[prettifySymbol(symbol)] = {

View File

@ -2,11 +2,67 @@
<div class="row">
<div class="col">
<h1 class="h4 mb-3 text-center" i18n>
Hello, {{ portfolioPublicDetails?.alias ?? 'someone' }} has shared a
Hello, {{ publicPortfolioDetails?.alias ?? 'someone' }} has shared a
<strong>Portfolio</strong> with you!
</h1>
</div>
</div>
<div class="row">
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isPercent]="true"
[precision]="2"
[value]="
publicPortfolioDetails?.performance?.['1d']?.relativeChange ??
undefined
"
>Today</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isPercent]="true"
[precision]="2"
[value]="
publicPortfolioDetails?.performance?.['ytd']?.relativeChange ??
undefined
"
>This year</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isPercent]="true"
[precision]="2"
[value]="
publicPortfolioDetails?.performance?.['max']?.relativeChange ??
undefined
"
>From the beginning</gf-value
>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="proportion-charts row">
<div class="col-md-12 allocations-by-symbol">
<mat-card appearance="outlined" class="mb-3">
@ -24,7 +80,7 @@
</mat-card-content>
</mat-card>
</div>
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -43,7 +99,7 @@
</mat-card>
</div>
}
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -60,7 +116,7 @@
</mat-card>
</div>
}
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -79,7 +135,7 @@
</div>
}
</div>
@if (portfolioPublicDetails?.hasDetails) {
@if (publicPortfolioDetails?.hasDetails) {
<div class="row world-map-chart">
<div class="col-lg">
<mat-card appearance="outlined" class="mb-3">

View File

@ -15,7 +15,7 @@ export class PersonalFinanceToolsPageComponent implements OnDestroy {
public personalFinanceTools = personalFinanceTools.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
private unsubscribeSubject = new Subject<void>();

View File

@ -21,10 +21,10 @@ export class GfProductPageComponent implements OnInit {
public price: number;
public product1: Product;
public product2: Product;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'/' + $localize`:snake-case:resources`,
'personal-finance-tools'
];
public tags: string[];

View File

@ -14,9 +14,9 @@ import { Subject } from 'rxjs';
export class ResourcesPageComponent implements OnInit {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'/' + $localize`:snake-case:resources`,
'personal-finance-tools'
];

View File

@ -36,8 +36,8 @@ import {
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PublicPortfolioResponse,
User
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
@ -611,9 +611,13 @@ export class DataService {
);
}
public fetchPortfolioPublic(aId: string) {
public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
}
public fetchPublicPortfolio(aAccessId: string) {
return this.http
.get<PortfolioPublicDetails>(`/api/v1/portfolio/public/${aId}`)
.get<PublicPortfolioResponse>(`/api/v1/public/${aAccessId}/portfolio`)
.pipe(
map((response) => {
if (response.holdings) {
@ -631,10 +635,6 @@ export class DataService {
);
}
public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
accessToken

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,9 @@ export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE = 1;
export const DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA = 1;
export const DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT = 1;
export const DEFAULT_ROOT_URL = 'https://localhost:4200';
// USX is handled separately

View File

@ -16,6 +16,7 @@ export interface AdminMarketDataItem {
id: string;
isBenchmark?: boolean;
isUsedByUsersWithSubscription?: boolean;
lastMarketPrice: number;
marketDataItemCount: number;
name: string;
sectorsCount: number;

View File

@ -31,7 +31,6 @@ import type { PortfolioItem } from './portfolio-item.interface';
import type { PortfolioOverview } from './portfolio-overview.interface';
import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioPublicDetails } from './portfolio-public-details.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioReport } from './portfolio-report.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
@ -44,6 +43,7 @@ import type { ImportResponse } from './responses/import-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface';
import type { Subscription } from './subscription.interface';
@ -91,12 +91,12 @@ export {
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioPublicDetails,
PortfolioReport,
PortfolioReportRule,
PortfolioSummary,
Position,
Product,
PublicPortfolioResponse,
ResponseError,
ScraperConfiguration,
Statistics,

View File

@ -1,6 +1,6 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { PortfolioPosition } from '../portfolio-position.interface';
export interface PortfolioPublicDetails {
export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
alias?: string;
hasDetails: boolean;
holdings: {
@ -22,3 +22,17 @@ export interface PortfolioPublicDetails {
>;
};
}
interface PublicPortfolioResponseV1 {
performance: {
'1d': {
relativeChange: number;
};
max: {
relativeChange: number;
};
ytd: {
relativeChange: number;
};
};
}

View File

@ -212,6 +212,16 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$115',
slogan: 'Flexible Financial Management'
},
{
founded: 2023,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'finanzfluss-copilot',
name: 'Finanzfluss Copilot',
origin: 'Germany',
pricingPerYear: '€69.99',
slogan: 'Portfolio Tracker für dein Vermögen'
},
{
founded: 2020,
key: 'finary',

View File

@ -22,5 +22,5 @@ export class GfMembershipCardComponent {
@Input() public expiresAt: string;
@Input() public name: string;
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -5,7 +5,7 @@
<ng-template #label><ng-content></ng-content></ng-template>
<ng-container *ngIf="value || value === 0 || value === null">
<div
class="d-flex"
class="align-items-center d-flex"
[ngClass]="position === 'end' ? 'justify-content-end' : ''"
>
<ng-container *ngIf="isNumber || value === null">

View File

@ -70,7 +70,6 @@
"!{projectRoot}/webpack.config.js"
]
},
"nxCloudAccessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
"parallel": 1,
"defaultBase": "origin/main"
}

68
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.106.0",
"version": "2.108.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.106.0",
"version": "2.108.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -40,7 +40,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.0",
"@prisma/client": "5.19.1",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0",
@ -84,7 +84,7 @@
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"prisma": "5.19.0",
"prisma": "5.19.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "15.11.0",
@ -9646,9 +9646,9 @@
"dev": true
},
"node_modules/@prisma/client": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.0.tgz",
"integrity": "sha512-CzOpau+q1kEWQyoQMvlnXIHqPvwmWbh48xZ4n8KWbAql0p8PC0BIgSTYW5ncxXa4JSEff0tcoxSZB874wDstdg==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz",
"integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -9664,48 +9664,48 @@
}
},
"node_modules/@prisma/debug": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.0.tgz",
"integrity": "sha512-+b/G0ubAZlrS+JSiDhXnYV5DF/aTJ3pinktkiV/L4TtLRLZO6SVGyFELgxBsicCTWJ2ZMu5vEV/jTtYCdjFTRA==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz",
"integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.0.tgz",
"integrity": "sha512-UtW+0m4HYoRSSR3LoDGKF3Ud4BSMWYlLEt4slTnuP1mI+vrV3zaDoiAPmejdAT76vCN5UqnWURbkXxf66nSylQ==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz",
"integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.0",
"@prisma/engines-version": "5.19.0-31.5fe21811a6ba0b952a3bc71400666511fe3b902f",
"@prisma/fetch-engine": "5.19.0",
"@prisma/get-platform": "5.19.0"
"@prisma/debug": "5.19.1",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"@prisma/fetch-engine": "5.19.1",
"@prisma/get-platform": "5.19.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.19.0-31.5fe21811a6ba0b952a3bc71400666511fe3b902f",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.0-31.5fe21811a6ba0b952a3bc71400666511fe3b902f.tgz",
"integrity": "sha512-GimI9aZIFy/yvvR11KfXRn3pliFn1QAkdebVlsXlnoh5uk0YhLblVmeYiHfsu+wDA7BeKqYT4sFfzg8mutzuWw==",
"version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz",
"integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.0.tgz",
"integrity": "sha512-oOiPNtmJX0cP/ebu7BBEouJvCw8T84/MFD/Hf2zlqjxkK4ojl38bB9i9J5LAxotL6WlYVThKdxc7HqoWnPOhqQ==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz",
"integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.0",
"@prisma/engines-version": "5.19.0-31.5fe21811a6ba0b952a3bc71400666511fe3b902f",
"@prisma/get-platform": "5.19.0"
"@prisma/debug": "5.19.1",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"@prisma/get-platform": "5.19.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.0.tgz",
"integrity": "sha512-s9DWkZKnuP4Y8uy6yZfvqQ/9X3/+2KYf3IZUVZz5OstJdGBJrBlbmIuMl81917wp5TuK/1k2TpHNCEdpYLPKmg==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz",
"integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.0"
"@prisma/debug": "5.19.1"
}
},
"node_modules/@redis/bloom": {
@ -28820,13 +28820,13 @@
}
},
"node_modules/prisma": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.0.tgz",
"integrity": "sha512-Pu7lUKpVyTx8cVwM26dYh8NdvMOkMnJXzE8L6cikFuR4JwyMU5NKofQkWyxJKlTT4fNjmcnibTvklV8oVMrn+g==",
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz",
"integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.19.0"
"@prisma/engines": "5.19.1"
},
"bin": {
"prisma": "build/index.js"

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.107.1",
"version": "2.109.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -84,7 +84,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.0",
"@prisma/client": "5.19.1",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0",
@ -128,7 +128,7 @@
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"prisma": "5.19.0",
"prisma": "5.19.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "15.11.0",