Browse Source

Merge branch 'main' into pr/3393

pull/3393/head
Thomas Kaul 1 year ago
parent
commit
bcb08260a5
  1. 42
      CHANGELOG.md
  2. 20
      Dockerfile
  3. 2
      README.md
  4. 4
      apps/api/src/app/benchmark/benchmark.controller.ts
  5. 192
      apps/api/src/app/benchmark/benchmark.service.ts
  6. 6
      apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts
  7. 2
      apps/api/src/app/cache/cache.controller.ts
  8. 38
      apps/api/src/app/order/order.controller.ts
  9. 15
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  10. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  11. 16
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 71
      apps/api/src/helper/portfolio.helper.ts
  13. 2
      apps/client/src/app/app.component.scss
  14. 11
      apps/client/src/app/app.component.ts
  15. 2
      apps/client/src/app/app.module.ts
  16. 18
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  17. 9
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  18. 1
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  19. 21
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  20. 2
      apps/client/src/app/components/header/header.component.scss
  21. 18
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  22. 9
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  23. 1
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  24. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  25. 3
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  26. 10
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  27. 2
      apps/client/src/app/components/rule/rule.component.scss
  28. 2
      apps/client/src/app/components/toggle/toggle.component.scss
  29. 2
      apps/client/src/app/components/user-account-access/user-account-access.scss
  30. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.scss
  31. 20
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  32. 2
      apps/client/src/app/components/user-account-settings/user-account-settings.scss
  33. 2
      apps/client/src/app/components/world-map-chart/world-map-chart.component.scss
  34. 25
      apps/client/src/app/core/layout.service.ts
  35. 27
      apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts
  36. 11
      apps/client/src/app/core/notification/alert-dialog/alert-dialog.html
  37. 2
      apps/client/src/app/core/notification/alert-dialog/alert-dialog.scss
  38. 6
      apps/client/src/app/core/notification/alert-dialog/interfaces/interfaces.ts
  39. 41
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts
  40. 20
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.html
  41. 2
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.scss
  42. 5
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.type.ts
  43. 9
      apps/client/src/app/core/notification/confirmation-dialog/interfaces/interfaces.ts
  44. 19
      apps/client/src/app/core/notification/interfaces/interfaces.ts
  45. 18
      apps/client/src/app/core/notification/notification.module.ts
  46. 83
      apps/client/src/app/core/notification/notification.service.ts
  47. 2
      apps/client/src/app/pages/about/about-page.scss
  48. 2
      apps/client/src/app/pages/about/changelog/changelog-page.scss
  49. 2
      apps/client/src/app/pages/about/license/license-page.scss
  50. 2
      apps/client/src/app/pages/about/overview/about-overview-page.scss
  51. 2
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.scss
  52. 6
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  53. 2
      apps/client/src/app/pages/admin/admin-page.scss
  54. 2
      apps/client/src/app/pages/blog/blog-page.scss
  55. 2
      apps/client/src/app/pages/faq/faq-page.scss
  56. 2
      apps/client/src/app/pages/features/features-page.scss
  57. 2
      apps/client/src/app/pages/home/home-page.scss
  58. 2
      apps/client/src/app/pages/landing/landing-page.scss
  59. 2
      apps/client/src/app/pages/open/open-page.scss
  60. 27
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  61. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.scss
  62. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  63. 7
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  64. 2
      apps/client/src/app/pages/portfolio/allocations/allocations-page.scss
  65. 3
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  66. 2
      apps/client/src/app/pages/portfolio/portfolio-page.scss
  67. 2
      apps/client/src/app/pages/pricing/pricing-page.scss
  68. 2
      apps/client/src/app/pages/public/public-page.scss
  69. 2
      apps/client/src/app/pages/register/register-page.scss
  70. 2
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.scss
  71. 2
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.scss
  72. 2
      apps/client/src/app/pages/resources/resources-page.scss
  73. 2
      apps/client/src/app/pages/user-account/user-account-page.scss
  74. 2
      apps/client/src/app/pages/zen/zen-page.scss
  75. 16
      apps/client/src/app/services/data.service.ts
  76. 588
      apps/client/src/locales/messages.ca.xlf
  77. 562
      apps/client/src/locales/messages.de.xlf
  78. 562
      apps/client/src/locales/messages.es.xlf
  79. 562
      apps/client/src/locales/messages.fr.xlf
  80. 616
      apps/client/src/locales/messages.it.xlf
  81. 576
      apps/client/src/locales/messages.nl.xlf
  82. 818
      apps/client/src/locales/messages.pl.xlf
  83. 682
      apps/client/src/locales/messages.pt.xlf
  84. 600
      apps/client/src/locales/messages.tr.xlf
  85. 530
      apps/client/src/locales/messages.xlf
  86. 562
      apps/client/src/locales/messages.zh.xlf
  87. 6
      apps/client/src/styles.scss
  88. 4
      apps/client/src/styles/theme.scss
  89. 1
      docker/docker-compose.yml
  90. 71
      libs/common/src/lib/calculation-helper.ts
  91. 172
      libs/common/src/lib/personal-finance-tools.ts
  92. 2
      libs/ui/src/lib/activities-filter/activities-filter.component.scss
  93. 2
      libs/ui/src/lib/activity-type/activity-type.component.scss
  94. 2
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss
  95. 2
      libs/ui/src/lib/assistant/assistant.scss
  96. 2
      libs/ui/src/lib/fire-calculator/fire-calculator.component.scss
  97. 3
      libs/ui/src/lib/i18n.ts
  98. 2
      libs/ui/src/lib/no-transactions-info/no-transactions-info.component.scss
  99. 18
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  100. 4
      libs/ui/src/lib/trend-indicator/trend-indicator.component.html

42
CHANGELOG.md

@ -7,10 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Set up a notification service for alert and confirmation dialogs
### Changed
- Refactored the dark theme CSS selector
- Improved the language localization for German (`de`)
- Upgraded `zone.js` from version `0.14.7` to `0.14.10`
### Fixed
- Removed `read_only: true` from the `docker-compose.yml` file to allow `prisma` to run migrations
## 2.103.0 - 2024-08-10
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
- Enabled Catalan (`ca`) as an option in the user settings (experimental)
- Enabled Polish (`pl`) as an option in the user settings (experimental)
- Improved the language localization for Portuguese (`pt`)
- Optimized the docker image layers to reduce the image size
- Updated the binary targets of `debian-openssl` for `prisma`
- Upgraded `prisma` from version `5.17.0` to `5.18.0`
## 2.102.0 - 2024-08-07
### Added
- Added support to clone an activity from the account detail dialog (experimental)
- Added support to edit an activity from the account detail dialog (experimental)
- Added support to clone an activity from the holding detail dialog (experimental)
- Added support to edit an activity from the holding detail dialog (experimental)
### Changed
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
### Fixed
- Fixed the cache flush endpoint response
## 2.101.0 - 2024-08-03
### Changed

20
Dockerfile

@ -3,6 +3,14 @@ FROM --platform=$BUILDPLATFORM node:20-slim AS builder
# Build application and add additional files
WORKDIR /ghostfolio
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md
@ -11,13 +19,6 @@ COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
@ -58,9 +59,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chown -R node:node /ghostfolio
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

2
README.md

@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Self-hosting

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

@ -1,8 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
@ -113,7 +113,7 @@ export class BenchmarkController {
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getInterval(
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
new Date(startDateString)
);

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

@ -29,15 +29,19 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import {
addHours,
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
@ -92,99 +96,28 @@ export class BenchmarkService {
enableSharing = false,
useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) {
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
const cachedBenchmarkValue = await this.redisCacheService.get(
this.CACHE_KEY_BENCHMARKS
);
if (benchmarks) {
return benchmarks;
}
} catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = useCache;
const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({
enableSharing
});
}
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('2 hours') / 1000
);
return benchmarks;
} catch {}
}
return benchmarks;
return this.calculateAndCacheBenchmarks({ enableSharing });
}
public async getBenchmarkAssetProfiles({
@ -422,6 +355,97 @@ export class BenchmarkService {
};
}
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
Logger.debug('Calculate benchmarks', 'BenchmarkService');
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
const expiration = addHours(new Date(), 2);
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{
benchmarks,
expiration: expiration.getTime()
}),
ms('12 hours') / 1000
);
}
return benchmarks;
}
private getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {

6
apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts

@ -0,0 +1,6 @@
import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
export interface BenchmarkValue {
benchmarks: BenchmarkResponse['benchmarks'];
expiration: number;
}

2
apps/api/src/app/cache/cache.controller.ts

@ -14,6 +14,6 @@ export class CacheController {
@Post('flush')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> {
return this.redisCacheService.reset();
await this.redisCacheService.reset();
}
}

38
apps/api/src/app/order/order.controller.ts

@ -1,12 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -110,7 +110,7 @@ export class OrderController {
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
({ endDate, startDate } = getIntervalFromDateRange(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -140,6 +140,38 @@ export class OrderController {
return { activities, count };
}
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<Activity> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
const activity = activities.find((activity) => {
return activity.id === id;
});
if (!activity) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return activity;
}
@HasPermission(permissions.createOrder)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -4,13 +4,11 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
@ -141,7 +139,7 @@ export abstract class PortfolioCalculator {
this.useCache = false; // TODO: useCache
this.userId = userId;
const { endDate, startDate } = getInterval(
const { endDate, startDate } = getIntervalFromDateRange(
'max',
subDays(dateOfFirstActivity, 1)
);
@ -371,7 +369,7 @@ export abstract class PortfolioCalculator {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
] ?? 1
);
const marketPriceInBaseCurrency = (
@ -659,7 +657,10 @@ export abstract class PortfolioCalculator {
return [];
}
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
this.getStartDate()
);
const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation

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

@ -7,7 +7,6 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
@ -15,6 +14,7 @@ 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
@ -259,7 +259,7 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,

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

@ -4,10 +4,7 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -18,7 +15,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
@ -72,7 +72,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, uniq, uniqBy } from 'lodash';
import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
@ -933,7 +933,7 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const { endDate } = getInterval(dateRange);
const { endDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -1115,7 +1115,7 @@ export class PortfolioService {
)
);
const { endDate, startDate } = getInterval(dateRange);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
console.time('------- PortfolioService.getPerformance - 2');

71
apps/api/src/helper/portfolio.helper.ts

@ -1,17 +1,4 @@
import { resetHours } from '@ghostfolio/common/helper';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) {
let factor: number;
@ -30,61 +17,3 @@ export function getFactor(activityType: ActivityType) {
return factor;
}
export function getInterval(
aDateRange: DateRange,
portfolioStart = new Date(0)
) {
let endDate = endOfDay(new Date(Date.now()));
let startDate = portfolioStart;
switch (aDateRange) {
case '1d':
startDate = max([
startDate,
subDays(resetHours(new Date(Date.now())), 1)
]);
break;
case 'mtd':
startDate = max([
startDate,
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1)
]);
break;
case 'wtd':
startDate = max([
startDate,
subDays(
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }),
1
)
]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date(Date.now()))), 1)
]);
break;
case '1y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 1)
]);
break;
case '5y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 5)
]);
break;
case 'max':
break;
default:
// '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(aDateRange));
startDate = max([startDate, new Date(aDateRange)]);
}
return { endDate, startDate };
}

2
apps/client/src/app/app.component.scss

@ -46,7 +46,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}

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

@ -255,6 +255,10 @@ export class AppComponent implements OnDestroy, OnInit {
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
@ -262,10 +266,11 @@ export class AppComponent implements OnDestroy, OnInit {
hasPermissionToUpdateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!user?.settings?.isRestrictedView,
!this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '95vw' : '50rem',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -292,9 +297,9 @@ export class AppComponent implements OnDestroy, OnInit {
);
if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme');
this.document.body.classList.add('theme-dark');
} else {
this.document.body.classList.remove('is-dark-theme');
this.document.body.classList.remove('theme-dark');
}
this.document

2
apps/client/src/app/app.module.ts

@ -33,6 +33,7 @@ import { GfSubscriptionInterstitialDialogModule } from './components/subscriptio
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
import { GfNotificationModule } from './core/notification/notification.module';
export function NgxStripeFactory(): string {
return environment.stripePublicKey;
@ -47,6 +48,7 @@ export function NgxStripeFactory(): string {
BrowserModule,
GfHeaderModule,
GfLogoComponent,
GfNotificationModule,
GfSubscriptionInterstitialDialogModule,
MarkdownModule.forRoot(),
MatAutocompleteModule,

18
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -23,6 +23,7 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
@ -66,6 +67,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private router: Router,
private userService: UserService
) {
this.userService.stateChanged
@ -92,6 +94,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchPortfolioPerformance();
}
public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, createDialog: true }
});
this.dialogRef.close();
}
public onClose() {
this.dialogRef.close();
}
@ -147,6 +157,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchActivities();
}
public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
this.dialogRef.close();
}
private fetchAccount() {
this.dataService
.fetchAccount(this.data.accountId)

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

@ -101,10 +101,17 @@
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateOrder &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(sortChanged)="onSortChanged($event)"
/>

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

@ -2,4 +2,5 @@ export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateOrder: boolean;
}

21
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -1,3 +1,5 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { getLocale } from '@ghostfolio/common/helper';
import {
@ -54,7 +56,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {}
public constructor(
private notificationService: NotificationService,
private router: Router
) {}
public ngOnInit() {}
@ -97,13 +102,13 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
public onDeleteAccount(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this account?`
);
if (confirmation) {
this.accountDeleted.emit(aId);
}
this.notificationService.confirm({
confirmFn: () => {
this.accountDeleted.emit(aId);
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete this account?`
});
}
public onOpenAccountDetailDialog(accountId: string) {

2
apps/client/src/app/components/header/header.component.scss

@ -50,7 +50,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.mat-toolbar {
background-color: var(--dark-background);

18
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -48,6 +48,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -141,6 +142,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private router: Router,
private userService: UserService
) {}
@ -424,6 +426,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.tagInput.nativeElement.value = '';
}
public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, createDialog: true }
});
this.dialogRef.close();
}
public onClose() {
this.dialogRef.close();
}
@ -456,6 +466,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
);
}
public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

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

@ -346,12 +346,19 @@
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateOrder &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
[showNameColumn]="false"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
/>
</mat-tab>

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

@ -8,6 +8,7 @@ export interface HoldingDetailDialogParams {
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateOrder: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: string;

1
apps/client/src/app/components/home-holdings/home-holdings.html

@ -38,6 +38,7 @@
<gf-treemap-chart
class="mt-3"
cursor="pointer"
[dateRange]="user?.settings?.dateRange"
[holdings]="holdings"
(treemapChartClicked)="onSymbolClicked($event)"
/>

3
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -14,7 +14,7 @@ import {
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ColorScheme, DateRange, GroupBy } from '@ghostfolio/common/types';
import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import {
ChangeDetectionStrategy,
@ -58,7 +58,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() isInPercent = false;
@Input() isLoading = false;
@Input() locale = getLocale();
@Input() range: DateRange = 'max';
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;

10
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import {
getLocale,
getNumberFormatDecimal,
@ -39,7 +40,7 @@ export class PortfolioPerformanceComponent implements OnChanges {
@ViewChild('value') value: ElementRef;
public constructor() {}
public constructor(private notificationService: NotificationService) {}
public ngOnChanges() {
this.precision = this.precision >= 0 ? this.precision : 2;
@ -74,12 +75,15 @@ export class PortfolioPerformanceComponent implements OnChanges {
}
public onShowErrors() {
const errorMessageParts = [$localize`Market data is delayed for`];
const errorMessageParts = [];
for (const error of this.errors) {
errorMessageParts.push(`${error.symbol} (${error.dataSource})`);
}
alert(errorMessageParts.join('\n'));
this.notificationService.alert({
message: errorMessageParts.join('<br />'),
title: $localize`Market data is delayed for`
});
}
}

2
apps/client/src/app/components/rule/rule.component.scss

@ -20,7 +20,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.icon-container {
background-color: rgba(var(--light-primary-text), 0.05);
}

2
apps/client/src/app/components/toggle/toggle.component.scss

@ -25,7 +25,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.mat-mdc-radio-button {
&.mat-mdc-radio-checked {
background-color: rgba(var(--light-dividers));

2
apps/client/src/app/components/user-account-access/user-account-access.scss

@ -3,6 +3,6 @@
display: block;
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/components/user-account-membership/user-account-membership.scss

@ -4,6 +4,6 @@
height: 100%;
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

20
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -73,12 +73,10 @@
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="ca"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
<mat-option value="ca"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
}
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh"
@ -103,12 +101,10 @@
>)</mat-option
>
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
<mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
}
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container

2
apps/client/src/app/components/user-account-settings/user-account-settings.scss

@ -17,6 +17,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/components/world-map-chart/world-map-chart.component.scss

@ -21,7 +21,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
::ng-deep {
.svgMap-map-wrapper {
.svgMap-country {

25
apps/client/src/app/core/layout.service.ts

@ -1,16 +1,39 @@
import { Injectable } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, Subject } from 'rxjs';
import { NotificationService } from './notification/notification.service';
@Injectable({
providedIn: 'root'
})
export class LayoutService {
public static readonly DEFAULT_NOTIFICATION_MAX_WIDTH = '50rem';
public static readonly DEFAULT_NOTIFICATION_WIDTH = '75vw';
public shouldReloadContent$: Observable<void>;
private shouldReloadSubject = new Subject<void>();
public constructor() {
public constructor(
private deviceService: DeviceDetectorService,
private notificationService: NotificationService
) {
this.shouldReloadContent$ = this.shouldReloadSubject.asObservable();
const deviceType = this.deviceService.getDeviceInfo().deviceType;
this.notificationService.setDialogWidth(
deviceType === 'mobile'
? '95vw'
: LayoutService.DEFAULT_NOTIFICATION_WIDTH
);
this.notificationService.setDialogMaxWidth(
deviceType === 'mobile'
? '95vw'
: LayoutService.DEFAULT_NOTIFICATION_MAX_WIDTH
);
}
public getShouldReloadSubject() {

27
apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { IAlertDialogParams } from './interfaces/interfaces';
@Component({
imports: [CommonModule, MatButtonModule, MatDialogModule],
selector: 'gf-alert-dialog',
standalone: true,
styleUrls: ['./alert-dialog.scss'],
templateUrl: './alert-dialog.html'
})
export class GfAlertDialogComponent {
public discardLabel: string;
public message: string;
public title: string;
public constructor(public dialogRef: MatDialogRef<GfAlertDialogComponent>) {}
public initialize(aParams: IAlertDialogParams) {
this.discardLabel = aParams.discardLabel;
this.message = aParams.message;
this.title = aParams.title;
}
}

11
apps/client/src/app/core/notification/alert-dialog/alert-dialog.html

@ -0,0 +1,11 @@
@if (title) {
<div mat-dialog-title [innerHTML]="title"></div>
}
@if (message) {
<div mat-dialog-content [innerHTML]="message"></div>
}
<div align="end" mat-dialog-actions>
<button mat-button (click)="dialogRef.close()">{{ discardLabel }}</button>
</div>

2
apps/client/src/app/core/notification/alert-dialog/alert-dialog.scss

@ -0,0 +1,2 @@
:host {
}

6
apps/client/src/app/core/notification/alert-dialog/interfaces/interfaces.ts

@ -0,0 +1,6 @@
export interface IAlertDialogParams {
confirmLabel?: string;
discardLabel?: string;
message?: string;
title: string;
}

41
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts

@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ConfirmationDialogType } from './confirmation-dialog.type';
import { IConfirmDialogParams } from './interfaces/interfaces';
@Component({
imports: [CommonModule, MatButtonModule, MatDialogModule],
selector: 'gf-confirmation-dialog',
standalone: true,
styleUrls: ['./confirmation-dialog.scss'],
templateUrl: './confirmation-dialog.html'
})
export class GfConfirmationDialogComponent {
public confirmLabel: string;
public confirmType: ConfirmationDialogType;
public discardLabel: string;
public message: string;
public title: string;
public constructor(
public dialogRef: MatDialogRef<GfConfirmationDialogComponent>
) {}
@HostListener('window:keyup', ['$event'])
public keyEvent(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.dialogRef.close('confirm');
}
}
public initialize(aParams: IConfirmDialogParams) {
this.confirmLabel = aParams.confirmLabel;
this.confirmType = aParams.confirmType;
this.discardLabel = aParams.discardLabel;
this.message = aParams.message;
this.title = aParams.title;
}
}

20
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.html

@ -0,0 +1,20 @@
@if (title) {
<div mat-dialog-title [innerHTML]="title"></div>
}
@if (message) {
<div mat-dialog-content [innerHTML]="message"></div>
}
<div align="end" mat-dialog-actions>
<button mat-button (click)="dialogRef.close('discard')">
{{ discardLabel }}
</button>
<button
mat-flat-button
[color]="confirmType"
(click)="dialogRef.close('confirm')"
>
{{ confirmLabel }}
</button>
</div>

2
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.scss

@ -0,0 +1,2 @@
:host {
}

5
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.type.ts

@ -0,0 +1,5 @@
export enum ConfirmationDialogType {
Accent = 'accent',
Primary = 'primary',
Warn = 'warn'
}

9
apps/client/src/app/core/notification/confirmation-dialog/interfaces/interfaces.ts

@ -0,0 +1,9 @@
import { ConfirmationDialogType } from '../confirmation-dialog.type';
export interface IConfirmDialogParams {
confirmLabel?: string;
confirmType: ConfirmationDialogType;
discardLabel?: string;
message?: string;
title: string;
}

19
apps/client/src/app/core/notification/interfaces/interfaces.ts

@ -0,0 +1,19 @@
import { ConfirmationDialogType } from '../confirmation-dialog/confirmation-dialog.type';
export interface IAlertParams {
discardFn?: () => void;
discardLabel?: string;
message?: string;
title: string;
}
export interface IConfirmParams {
confirmFn: () => void;
confirmLabel?: string;
confirmType?: ConfirmationDialogType;
disableClose?: boolean;
discardFn?: () => void;
discardLabel?: string;
message?: string;
title: string;
}

18
apps/client/src/app/core/notification/notification.module.ts

@ -0,0 +1,18 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { GfAlertDialogComponent } from './alert-dialog/alert-dialog.component';
import { GfConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
import { NotificationService } from './notification.service';
@NgModule({
imports: [
CommonModule,
GfAlertDialogComponent,
GfConfirmationDialogComponent,
MatDialogModule
],
providers: [NotificationService]
})
export class GfNotificationModule {}

83
apps/client/src/app/core/notification/notification.service.ts

@ -0,0 +1,83 @@
import { translate } from '@ghostfolio/ui/i18n';
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { isFunction } from 'lodash';
import { GfAlertDialogComponent } from './alert-dialog/alert-dialog.component';
import { GfConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
import { ConfirmationDialogType } from './confirmation-dialog/confirmation-dialog.type';
import { IAlertParams, IConfirmParams } from './interfaces/interfaces';
@Injectable()
export class NotificationService {
private dialogMaxWidth: string;
private dialogWidth: string;
public constructor(private matDialog: MatDialog) {}
public alert(aParams: IAlertParams) {
if (!aParams.discardLabel) {
aParams.discardLabel = translate('CLOSE');
}
const dialog = this.matDialog.open(GfAlertDialogComponent, {
autoFocus: false,
maxWidth: this.dialogMaxWidth,
width: this.dialogWidth
});
dialog.componentInstance.initialize({
discardLabel: aParams.discardLabel,
message: aParams.message,
title: aParams.title
});
return dialog.afterClosed().subscribe((result) => {
if (isFunction(aParams.discardFn)) {
aParams.discardFn();
}
});
}
public confirm(aParams: IConfirmParams) {
if (!aParams.confirmLabel) {
aParams.confirmLabel = translate('YES');
}
if (!aParams.discardLabel) {
aParams.discardLabel = translate('CANCEL');
}
const dialog = this.matDialog.open(GfConfirmationDialogComponent, {
autoFocus: false,
disableClose: aParams.disableClose || false,
maxWidth: this.dialogMaxWidth,
width: this.dialogWidth
});
dialog.componentInstance.initialize({
confirmLabel: aParams.confirmLabel,
confirmType: aParams.confirmType || ConfirmationDialogType.Primary,
discardLabel: aParams.discardLabel,
message: aParams.message,
title: aParams.title
});
return dialog.afterClosed().subscribe((result) => {
if (result === 'confirm' && isFunction(aParams.confirmFn)) {
aParams.confirmFn();
} else if (result === 'discard' && isFunction(aParams.discardFn)) {
aParams.discardFn();
}
});
}
public setDialogMaxWidth(aDialogMaxWidth: string) {
this.dialogMaxWidth = aDialogMaxWidth;
}
public setDialogWidth(aDialogWidth: string) {
this.dialogWidth = aDialogWidth;
}
}

2
apps/client/src/app/pages/about/about-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/about/changelog/changelog-page.scss

@ -35,6 +35,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/about/license/license-page.scss

@ -3,6 +3,6 @@
display: block;
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/about/overview/about-overview-page.scss

@ -24,7 +24,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
.about-container {

2
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.scss

@ -16,6 +16,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

6
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -221,7 +221,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
data: <AccountDetailDialogParams>{
accountId: aAccountId,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

2
apps/client/src/app/pages/admin/admin-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/blog/blog-page.scss

@ -9,6 +9,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/faq/faq-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/features/features-page.scss

@ -12,6 +12,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/home/home-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

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

@ -120,7 +120,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.button-container {
.mat-mdc-outlined-button {
background-color: var(--dark-background);

2
apps/client/src/app/pages/open/open-page.scss

@ -14,6 +14,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

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

@ -16,7 +16,6 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
@ -63,14 +62,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateActivityDialog();
if (params['activityId']) {
this.dataService
.fetchActivity(params['activityId'])
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((activity) => {
this.openCreateActivityDialog(activity);
});
} else {
this.openCreateActivityDialog();
}
} else if (params['editDialog']) {
if (this.dataSource && params['activityId']) {
const activity = this.dataSource.data.find(({ id }) => {
return id === params['activityId'];
});
this.openUpdateActivityDialog(activity);
if (params['activityId']) {
this.dataService
.fetchActivity(params['activityId'])
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((activity) => {
this.openUpdateActivityDialog(activity);
});
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
@ -242,7 +251,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.fetchActivities();
}
public onUpdateActivity(aActivity: OrderModel) {
public onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }
});

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.scss

@ -18,7 +18,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.mat-mdc-dialog-content {
.mat-datepicker-input {
&.mat-mdc-input-element:disabled {

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -53,7 +53,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.drop-area {
border-color: rgba(
var(--palette-foreground-divider-dark),

7
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -12,6 +12,7 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
@ -584,7 +585,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
data: <AccountDetailDialogParams>{
accountId: aAccountId,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

2
apps/client/src/app/pages/portfolio/allocations/allocations-page.scss

@ -43,7 +43,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.mat-mdc-progress-bar {
::ng-deep {
.mdc-linear-progress__buffer-bar {

3
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -282,7 +282,6 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
/>
</div>
</div>
@ -340,7 +339,6 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
[savingsRate]="savingsRate"
/>
</div>
@ -377,7 +375,6 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
/>
</div>
</div>

2
apps/client/src/app/pages/portfolio/portfolio-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/pricing/pricing-page.scss

@ -26,6 +26,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/public/public-page.scss

@ -17,6 +17,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/register/register-page.scss

@ -24,7 +24,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.button-container {
.mat-mdc-outlined-button {
background-color: var(--dark-background);

2
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.scss

@ -20,6 +20,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/resources/personal-finance-tools/product-page.scss

@ -16,7 +16,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
.call-to-action {

2
apps/client/src/app/pages/resources/resources-page.scss

@ -12,6 +12,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/user-account/user-account-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/pages/zen/zen-page.scss

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text));
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
color: rgb(var(--light-primary-text));
}

16
apps/client/src/app/services/data.service.ts

@ -4,7 +4,10 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
Activities,
Activity
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
@ -212,6 +215,17 @@ export class DataService {
);
}
public fetchActivity(aActivityId: string) {
return this.http.get<Activity>(`/api/v1/order/${aActivityId}`).pipe(
map((activity) => {
activity.createdAt = parseISO(<string>(<unknown>activity.createdAt));
activity.date = parseISO(<string>(<unknown>activity.date));
return activity;
})
);
}
public fetchDividends({
filters,
groupBy = 'month',

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

6
apps/client/src/styles.scss

@ -214,7 +214,7 @@ body {
}
}
&.is-dark-theme {
&.theme-dark {
background: var(--dark-background);
color: rgba(var(--light-primary-text));
@ -359,10 +359,6 @@ ngx-skeleton-loader {
.cdk-global-overlay-wrapper {
justify-content: center !important;
}
.cdk-overlay-pane {
max-width: 95vw !important;
}
}
.cursor-default {

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

@ -3,7 +3,7 @@
$dark-primary-text: rgba(black, 0.87);
$light-primary-text: white;
$mat-css-dark-theme-selector: '.is-dark-theme';
$mat-css-dark-theme-selector: '.theme-dark';
$gf-primary: (
50: var(--gf-theme-primary-50),
@ -99,7 +99,7 @@ $gf-theme-dark: mat.m2-define-dark-theme(
)
);
.is-dark-theme {
.theme-dark {
@include mat.all-component-colors($gf-theme-dark);
}

1
docker/docker-compose.yml

@ -2,7 +2,6 @@ services:
ghostfolio:
image: docker.io/ghostfolio/ghostfolio:latest
init: true
read_only: true
cap_drop:
- ALL
security_opt:

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

@ -1,6 +1,19 @@
import { Big } from 'big.js';
import {
endOfDay,
endOfYear,
max,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subYears
} from 'date-fns';
import { isNumber } from 'lodash';
import { resetHours } from './helper';
import { DateRange } from './types';
export function getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercentage
@ -18,3 +31,61 @@ export function getAnnualizedPerformancePercent({
return new Big(0);
}
export function getIntervalFromDateRange(
aDateRange: DateRange,
portfolioStart = new Date(0)
) {
let endDate = endOfDay(new Date(Date.now()));
let startDate = portfolioStart;
switch (aDateRange) {
case '1d':
startDate = max([
startDate,
subDays(resetHours(new Date(Date.now())), 1)
]);
break;
case 'mtd':
startDate = max([
startDate,
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1)
]);
break;
case 'wtd':
startDate = max([
startDate,
subDays(
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }),
1
)
]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date(Date.now()))), 1)
]);
break;
case '1y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 1)
]);
break;
case '5y':
startDate = max([
startDate,
subYears(resetHours(new Date(Date.now())), 5)
]);
break;
case 'max':
break;
default:
// '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(aDateRange));
startDate = max([startDate, new Date(aDateRange)]);
}
return { endDate, startDate };
}

172
libs/common/src/lib/personal-finance-tools.ts

@ -15,7 +15,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'allvue-systems',
name: 'Allvue Systems',
origin: `United States`,
origin: 'United States',
slogan: 'Investment Software Suite'
},
{
@ -30,7 +30,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'altoo',
name: 'Altoo Wealth Platform',
origin: `Switzerland`,
origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth'
},
{
@ -40,7 +40,7 @@ export const personalFinanceTools: Product[] = [
key: 'anlage.app',
languages: ['English'],
name: 'Anlage.App',
origin: `Austria`,
origin: 'Austria',
pricingPerYear: '$120',
slogan: 'Analyze and track your portfolio.'
},
@ -58,16 +58,27 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'beanvest',
name: 'Beanvest',
origin: `France`,
origin: 'France',
pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors'
},
{
founded: 2022,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'degiro-portfolio-tracker-by-capitalyse',
languages: ['English'],
name: 'DEGIRO Portfolio Tracker by Capitalyse',
origin: 'Netherlands',
pricingPerYear: '€24',
slogan: 'Democratizing Data Analytics'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'capitally',
name: 'Capitally',
origin: `Poland`,
origin: 'Poland',
pricingPerYear: '€50',
slogan: 'Optimize your investments performance'
},
@ -75,7 +86,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022,
key: 'capmon',
name: 'CapMon.org',
origin: `Germany`,
origin: 'Germany',
note: 'CapMon.org was discontinued in 2023',
slogan: 'Next Generation Assets Tracking'
},
@ -83,7 +94,7 @@ export const personalFinanceTools: Product[] = [
founded: 2019,
key: 'compound-planning',
name: 'Compound Planning',
origin: `United States`,
origin: 'United States',
slogan: 'Modern Wealth & Investment Management'
},
{
@ -92,7 +103,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'copilot-money',
name: 'Copilot Money',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$70',
slogan: 'Do money better with Copilot'
},
@ -110,7 +121,7 @@ export const personalFinanceTools: Product[] = [
key: 'delta',
name: 'Delta Investment Tracker',
note: 'Acquired by eToro',
origin: `Belgium`,
origin: 'Belgium',
slogan: 'The app to track all your investments. Make smart moves only.'
},
{
@ -120,7 +131,7 @@ export const personalFinanceTools: Product[] = [
key: 'divvydiary',
languages: ['Deutsch', 'English'],
name: 'DivvyDiary',
origin: `Germany`,
origin: 'Germany',
pricingPerYear: '€65',
slogan: 'Your personal Dividend Calendar'
},
@ -130,7 +141,7 @@ export const personalFinanceTools: Product[] = [
key: 'empower',
name: 'Empower',
note: 'Originally named as Personal Capital',
origin: `United States`,
origin: 'United States',
slogan: 'Get answers to your money questions'
},
{
@ -138,7 +149,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022,
key: 'eightfigures',
name: '8FIGURES',
origin: `United States`,
origin: 'United States',
slogan: 'Portfolio Tracker Designed by Professional Investors'
},
{
@ -147,7 +158,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'exirio',
name: 'Exirio',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$100',
slogan: 'All your wealth, in one place.'
},
@ -158,7 +169,7 @@ export const personalFinanceTools: Product[] = [
key: 'fina',
languages: ['English'],
name: 'Fina',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$115',
slogan: 'Flexible Financial Management'
},
@ -167,15 +178,26 @@ export const personalFinanceTools: Product[] = [
key: 'finary',
languages: ['Deutsch', 'English', 'Français'],
name: 'Finary',
origin: `United States`,
origin: 'United States',
slogan: 'Real-Time Portfolio Tracker & Stock Tracker'
},
{
founded: 2021,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'finateka',
languages: ['English'],
name: 'FINATEKA',
origin: 'United States',
slogan:
'The most convenient mobile application for personal finance accounting'
},
{
founded: 2023,
hasFreePlan: true,
key: 'finwise',
name: 'FinWise',
origin: `South Africa`,
origin: 'South Africa',
pricingPerYear: '€69.99',
slogan: 'Personal finances, simplified'
},
@ -185,7 +207,7 @@ export const personalFinanceTools: Product[] = [
key: 'folishare',
languages: ['Deutsch', 'English'],
name: 'folishare',
origin: `Austria`,
origin: 'Austria',
pricingPerYear: '$65',
slogan: 'Take control over your investments'
},
@ -196,7 +218,7 @@ export const personalFinanceTools: Product[] = [
key: 'getquin',
languages: ['Deutsch', 'English'],
name: 'getquin',
origin: `Germany`,
origin: 'Germany',
pricingPerYear: '€48',
slogan: 'Portfolio Tracker, Analysis & Community'
},
@ -205,7 +227,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'gospatz',
name: 'goSPATZ',
origin: `Germany`,
origin: 'Germany',
slogan: 'Volle Kontrolle über deine Investitionen'
},
{
@ -214,7 +236,7 @@ export const personalFinanceTools: Product[] = [
key: 'holistic-capital',
languages: ['Deutsch'],
name: 'Holistic',
origin: `Germany`,
origin: 'Germany',
slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true
},
@ -224,17 +246,25 @@ export const personalFinanceTools: Product[] = [
key: 'intuit-mint',
name: 'Intuit Mint',
note: 'Intuit Mint was discontinued in 2023',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$60',
slogan: 'Managing money, made simple'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'investify',
name: 'Investify',
origin: 'Pakistan',
slogan: 'Advanced portfolio tracking and stock market information'
},
{
founded: 2011,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'justetf',
name: 'justETF',
origin: `Germany`,
origin: 'Germany',
pricingPerYear: '€119',
slogan: 'ETF portfolios made simple'
},
@ -244,7 +274,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'koyfin',
name: 'Koyfin',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$468',
slogan: 'Comprehensive financial data analysis'
},
@ -254,7 +284,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'kubera',
name: 'Kubera®',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$150',
slogan: 'The Time Machine for your Net Worth'
},
@ -264,7 +294,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'magnifi',
name: 'Magnifi',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$132',
slogan: 'AI Investing Assistant'
},
@ -275,9 +305,9 @@ export const personalFinanceTools: Product[] = [
key: 'markets.sh',
languages: ['English'],
name: 'markets.sh',
origin: `Germany`,
origin: 'Germany',
pricingPerYear: '€168',
regions: [`Global`],
regions: ['Global'],
slogan: 'Track your investments'
},
{
@ -287,9 +317,9 @@ export const personalFinanceTools: Product[] = [
languages: ['English'],
name: 'Maybe Finance',
note: 'Maybe Finance was discontinued in 2023',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$145',
regions: [`United States`],
regions: ['United States'],
slogan: 'Your financial future, in your control'
},
{
@ -298,7 +328,7 @@ export const personalFinanceTools: Product[] = [
key: 'merlincrypto',
languages: ['English'],
name: 'Merlin',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$204',
regions: ['Canada', 'United States'],
slogan: 'The smartest way to track your crypto'
@ -309,7 +339,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'monarch-money',
name: 'Monarch Money',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$99.99',
slogan: 'The modern way to manage your money'
},
@ -327,7 +357,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'navexa',
name: 'Navexa',
origin: `Australia`,
origin: 'Australia',
pricingPerYear: '$90',
slogan: 'The Intelligent Portfolio Tracker'
},
@ -338,17 +368,28 @@ export const personalFinanceTools: Product[] = [
key: 'parqet',
name: 'Parqet',
note: 'Originally named as Tresor One',
origin: `Germany`,
origin: 'Germany',
pricingPerYear: '€88',
regions: ['Austria', 'Germany', 'Switzerland'],
slogan: 'Dein Vermögen immer im Blick'
},
{
founded: 2023,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'plainzer',
languages: ['English'],
name: 'Plainzer',
origin: 'Poland',
pricingPerYear: '$74',
slogan: 'Free dividend tracker for your portfolio'
},
{
founded: 2023,
hasSelfHostingAbility: false,
key: 'plannix',
name: 'Plannix',
origin: `Italy`,
origin: 'Italy',
slogan: 'Your Personal Finance Hub'
},
{
@ -358,9 +399,9 @@ export const personalFinanceTools: Product[] = [
key: 'pocketsmith',
languages: ['English'],
name: 'PocketSmith',
origin: `New Zealand`,
origin: 'New Zealand',
pricingPerYear: '$120',
regions: [`Global`],
regions: ['Global'],
slogan: 'Know where your money is going'
},
{
@ -369,7 +410,7 @@ export const personalFinanceTools: Product[] = [
key: 'portfolio-dividend-tracker',
languages: ['English', 'Nederlands'],
name: 'Portfolio Dividend Tracker',
origin: `Netherlands`,
origin: 'Netherlands',
pricingPerYear: '€60',
slogan: 'Manage all your portfolios'
},
@ -397,7 +438,7 @@ export const personalFinanceTools: Product[] = [
key: 'portseido',
languages: ['Deutsch', 'English', 'Français', 'Nederlands'],
name: 'Portseido',
origin: `Thailand`,
origin: 'Thailand',
pricingPerYear: '$96',
slogan: 'Portfolio Performance and Dividend Tracker'
},
@ -407,7 +448,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: true,
key: 'projectionlab',
name: 'ProjectionLab',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$108',
slogan: 'Build Financial Plans You Love.'
},
@ -416,16 +457,25 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'rocket-money',
name: 'Rocket Money',
origin: `United States`,
origin: 'United States',
slogan: 'Track your net worth'
},
{
founded: 2019,
hasSelfHostingAbility: false,
key: 'sarmaaya.pk',
name: 'Sarmaaya.pk Portfolio Tracking',
note: 'Sarmaaya.pk Portfolio Tracking was discontinued in 2024',
origin: 'Pakistan',
slogan: 'Unified platform for financial research and portfolio tracking'
},
{
founded: 2004,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'seeking-alpha',
name: 'Seeking Alpha',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$239',
slogan: 'Stock Market Analysis & Tools for Investors'
},
@ -433,7 +483,7 @@ export const personalFinanceTools: Product[] = [
founded: 2022,
key: 'segmio',
name: 'Segmio',
origin: `Romania`,
origin: 'Romania',
slogan: 'Wealth Management and Net Worth Tracking'
},
{
@ -442,9 +492,9 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'sharesight',
name: 'Sharesight',
origin: `New Zealand`,
origin: 'New Zealand',
pricingPerYear: '$135',
regions: [`Global`],
regions: ['Global'],
slogan: 'Stock Portfolio Tracker'
},
{
@ -459,7 +509,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'simple-portfolio',
name: 'Simple Portfolio',
origin: `Czech Republic`,
origin: 'Czech Republic',
pricingPerYear: '€80',
slogan: 'Stock Portfolio Tracker'
},
@ -469,7 +519,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'snowball-analytics',
name: 'Snowball Analytics',
origin: `France`,
origin: 'France',
pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker'
},
@ -478,20 +528,20 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'stock-events',
name: 'Stock Events',
origin: `Germany`,
origin: 'Germany',
slogan: 'Track all your Investments'
},
{
key: 'stockle',
name: 'Stockle',
origin: `Finland`,
origin: 'Finland',
slogan: 'Supercharge your investments tracking experience'
},
{
founded: 2008,
key: 'stockmarketeye',
name: 'StockMarketEye',
origin: `France`,
origin: 'France',
note: 'StockMarketEye was discontinued in 2023',
slogan: 'A Powerful Portfolio & Investment Tracking App'
},
@ -501,7 +551,7 @@ export const personalFinanceTools: Product[] = [
key: 'stonksfolio',
languages: ['English'],
name: 'Stonksfolio',
origin: `Bulgaria`,
origin: 'Bulgaria',
pricingPerYear: '€49.90',
slogan: 'Visualize all of your portfolios'
},
@ -510,7 +560,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'sumio',
name: 'Sumio',
origin: `Czech Republic`,
origin: 'Czech Republic',
pricingPerYear: '$20',
slogan: 'Sum up and build your wealth.'
},
@ -519,7 +569,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: false,
key: 'tiller',
name: 'Tiller',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$79',
slogan:
'Your financial life in a spreadsheet, automatically updated each day'
@ -530,7 +580,7 @@ export const personalFinanceTools: Product[] = [
key: 'utluna',
languages: ['Deutsch', 'English', 'Français'],
name: 'Utluna',
origin: `Switzerland`,
origin: 'Switzerland',
pricingPerYear: '$300',
slogan: 'Your Portfolio. Revealed.',
useAnonymously: true
@ -540,7 +590,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: true,
key: 'vyzer',
name: 'Vyzer',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$348',
slogan: 'Virtual Family Office for Smart Wealth Management'
},
@ -549,7 +599,7 @@ export const personalFinanceTools: Product[] = [
key: 'wallmine',
languages: ['English'],
name: 'wallmine',
origin: `Czech Republic`,
origin: 'Czech Republic',
pricingPerYear: '$600',
slogan: 'Make Smarter Investments'
},
@ -567,7 +617,7 @@ export const personalFinanceTools: Product[] = [
key: 'wealthica',
languages: ['English', 'Français'],
name: 'Wealthica',
origin: `Canada`,
origin: 'Canada',
pricingPerYear: '$50',
slogan: 'See all your investments in one place'
},
@ -577,13 +627,13 @@ export const personalFinanceTools: Product[] = [
key: 'wealthy-tracker',
languages: ['English'],
name: 'Wealthy Tracker',
origin: `India`,
origin: 'India',
slogan: 'One app to manage all your investments'
},
{
key: 'whal',
name: 'Whal',
origin: `United States`,
origin: 'United States',
slogan: 'Manage your investments in one place'
},
{
@ -594,8 +644,8 @@ export const personalFinanceTools: Product[] = [
languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'],
name: 'yeekatee',
note: 'yeekatee was discontinued in 2024',
origin: `Switzerland`,
regions: [`Global`],
origin: 'Switzerland',
regions: ['Global'],
slogan: 'Connect. Share. Invest.'
},
{
@ -604,7 +654,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'ynab',
name: 'YNAB (You Need a Budget)',
origin: `United States`,
origin: 'United States',
pricingPerYear: '$99',
slogan: 'Change Your Relationship With Money'
}

2
libs/ui/src/lib/activities-filter/activities-filter.component.scss

@ -15,7 +15,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.mat-mdc-form-field {
color: rgba(var(--light-primary-text));
}

2
libs/ui/src/lib/activity-type/activity-type.component.scss

@ -40,7 +40,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.activity-type-badge {
background-color: rgba(var(--palette-foreground-text-dark), 0.1) !important;
}

2
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss

@ -10,7 +10,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
&.has-focus {
a {
color: rgba(var(--dark-primary-text));

2
libs/ui/src/lib/assistant/assistant.scss

@ -26,7 +26,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
.date-range-selector-container {
border-color: rgba(var(--light-dividers));
}

2
libs/ui/src/lib/fire-calculator/fire-calculator.component.scss

@ -25,7 +25,7 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
::ng-deep {
.mdc-text-field--disabled {
.mdc-notched-outline__leading,

3
libs/ui/src/lib/i18n.ts

@ -6,7 +6,9 @@ const locales = {
ASSET_CLASS: $localize`Asset Class`,
ASSET_SUB_CLASS: $localize`Asset Sub Class`,
BUY_AND_SELL_ACTIVITIES_TOOLTIP: $localize`Buy and sell`,
CANCEL: $localize`Cancel`,
CORE: $localize`Core`,
CLOSE: $localize`Close`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source or Ghostfolio Basic easily`,
@ -26,6 +28,7 @@ const locales = {
TAG: $localize`Tag`,
YEAR: $localize`Year`,
YEARS: $localize`Years`,
YES: $localize`Yes`,
// Activity types
BUY: $localize`Buy`,

2
libs/ui/src/lib/no-transactions-info/no-transactions-info.component.scss

@ -11,6 +11,6 @@
}
}
:host-context(.is-dark-theme) {
:host-context(.theme-dark) {
border-color: rgba(var(--light-dividers));
}

18
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -1,8 +1,12 @@
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common';
import {
@ -23,7 +27,7 @@ import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js';
import { Chart } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { differenceInDays } from 'date-fns';
import { differenceInDays, max } from 'date-fns';
import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -41,6 +45,7 @@ export class GfTreemapChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() cursor: string;
@Input() dateRange: DateRange;
@Input() holdings: PortfolioPosition[];
@Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ -75,6 +80,8 @@ export class GfTreemapChartComponent
private initialize() {
this.isLoading = true;
const { endDate, startDate } = getIntervalFromDateRange(this.dateRange);
const data: ChartConfiguration['data'] = <any>{
datasets: [
{
@ -82,8 +89,11 @@ export class GfTreemapChartComponent
const annualizedNetPerformancePercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
new Date(),
ctx.raw._data.dateOfFirstActivity
endDate,
max([
ctx.raw._data.dateOfFirstActivity ?? new Date(0),
startDate
])
),
netPerformancePercentage: new Big(
ctx.raw._data.netPerformancePercentWithCurrencyEffect

4
libs/ui/src/lib/trend-indicator/trend-indicator.component.html

@ -8,9 +8,9 @@
}"
/>
} @else {
@if (marketState === 'closed' && range === '1d') {
@if (marketState === 'closed' && dateRange === '1d') {
<ion-icon class="text-muted" name="pause-circle-outline" [size]="size" />
} @else if (marketState === 'delayed' && range === '1d') {
} @else if (marketState === 'delayed' && dateRange === '1d') {
<ion-icon class="text-muted" name="time-outline" [size]="size" />
} @else if (value <= -0.0005) {
<ion-icon

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save