Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror
This commit is contained in:
commit
1ed71ed7ec
31
CHANGELOG.md
31
CHANGELOG.md
@ -7,13 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Considered the user’s 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
|
||||
|
||||
|
36
README.md
36
README.md
@ -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
|
||||
|
@ -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 };
|
||||
|
@ -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({
|
||||
|
134
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
134
apps/api/src/app/endpoints/public/public.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
49
apps/api/src/app/endpoints/public/public.module.ts
Normal 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 {}
|
@ -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)
|
||||
|
@ -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({
|
||||
|
@ -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>
|
||||
|
@ -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}`
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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: '' }),
|
||||
|
@ -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(
|
||||
|
@ -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>) {
|
||||
|
@ -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>
|
||||
) {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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: () => {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -142,6 +142,7 @@ export class AdminMarketDataComponent
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'lastMarketPrice',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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>();
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
>
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
});
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
||||
<gf-access-table
|
||||
[accesses]="accesses"
|
||||
[showActions]="hasPermissionToDeleteAccess"
|
||||
[user]="user"
|
||||
(accessDeleted)="onDeleteAccess($event)"
|
||||
/>
|
||||
@if (hasPermissionToCreateAccess) {
|
||||
|
@ -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';
|
||||
|
@ -56,6 +56,10 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mt-3 text-muted">
|
||||
<small i18n>No auto-renewal.</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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>();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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'
|
||||
];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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`];
|
||||
}
|
@ -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']">4’000 stars on GitHub</a> and
|
||||
<a [routerLink]="['/open']">800’000+ 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_">@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>
|
@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>();
|
||||
|
@ -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>();
|
||||
|
@ -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>();
|
||||
|
@ -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 = [
|
||||
{
|
||||
|
@ -93,6 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
{
|
||||
id: AssetClass.EQUITY,
|
||||
type: 'ASSET_CLASS'
|
||||
},
|
||||
{
|
||||
id: AssetClass.FIXED_INCOME,
|
||||
type: 'ASSET_CLASS'
|
||||
}
|
||||
],
|
||||
range: 'max'
|
||||
|
@ -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>();
|
||||
|
@ -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)] = {
|
||||
|
@ -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">
|
||||
|
@ -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>();
|
||||
|
||||
|
@ -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[];
|
||||
|
@ -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'
|
||||
];
|
||||
|
||||
|
@ -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
|
||||
|
BIN
apps/client/src/assets/images/blog/hacktoberfest-2024.png
Normal file
BIN
apps/client/src/assets/images/blog/hacktoberfest-2024.png
Normal file
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
@ -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
|
||||
|
@ -16,6 +16,7 @@ export interface AdminMarketDataItem {
|
||||
id: string;
|
||||
isBenchmark?: boolean;
|
||||
isUsedByUsersWithSubscription?: boolean;
|
||||
lastMarketPrice: number;
|
||||
marketDataItemCount: number;
|
||||
name: string;
|
||||
sectorsCount: number;
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
@ -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',
|
||||
|
@ -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`];
|
||||
}
|
||||
|
@ -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">
|
||||
|
1
nx.json
1
nx.json
@ -70,7 +70,6 @@
|
||||
"!{projectRoot}/webpack.config.js"
|
||||
]
|
||||
},
|
||||
"nxCloudAccessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
|
||||
"parallel": 1,
|
||||
"defaultBase": "origin/main"
|
||||
}
|
||||
|
68
package-lock.json
generated
68
package-lock.json
generated
@ -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"
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user