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 ## 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 ### 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` - Upgraded `Nx` from version `19.5.1` to `19.5.6`
### Fixed
- Fixed the cache flush endpoint response
## 2.101.0 - 2024-08-03 ## 2.101.0 - 2024-08-03
### Changed ### Changed

20
Dockerfile

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

2
README.md

@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### Frontend ### 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 ## 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { 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 { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -113,7 +113,7 @@ export class BenchmarkController {
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max' @Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getInterval( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) 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 { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addHours,
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
format, format,
isAfter,
isSameDay, isSameDay,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
@ -92,99 +96,28 @@ export class BenchmarkService {
enableSharing = false, enableSharing = false,
useCache = true useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> { } = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) { if (useCache) {
try { try {
benchmarks = JSON.parse( const cachedBenchmarkValue = await this.redisCacheService.get(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) this.CACHE_KEY_BENCHMARKS
); );
if (benchmarks) { const { benchmarks, expiration }: BenchmarkValue =
return benchmarks; JSON.parse(cachedBenchmarkValue);
}
} 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;
benchmarks = allTimeHighs.map((allTimeHigh, index) => { Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0; if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({
if (allTimeHigh?.marketPrice && marketPrice) { enableSharing
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) { return benchmarks;
await this.redisCacheService.set( } catch {}
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('2 hours') / 1000
);
} }
return benchmarks; return this.calculateAndCacheBenchmarks({ enableSharing });
} }
public async getBenchmarkAssetProfiles({ 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( private getMarketCondition(
aPerformanceInPercent: number aPerformanceInPercent: number
): Benchmark['marketCondition'] { ): 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') @Post('flush')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> { 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; 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 { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -110,7 +110,7 @@ export class OrderController {
let startDate: Date; let startDate: Date;
if (dateRange) { if (dateRange) {
({ endDate, startDate } = getInterval(dateRange)); ({ endDate, startDate } = getIntervalFromDateRange(dateRange));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -140,6 +140,38 @@ export class OrderController {
return { activities, count }; 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) @HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @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 { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
@ -141,7 +139,7 @@ export abstract class PortfolioCalculator {
this.useCache = false; // TODO: useCache this.useCache = false; // TODO: useCache
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getInterval( const { endDate, startDate } = getIntervalFromDateRange(
'max', 'max',
subDays(dateOfFirstActivity, 1) subDays(dateOfFirstActivity, 1)
); );
@ -371,7 +369,7 @@ export abstract class PortfolioCalculator {
const feeInBaseCurrency = item.fee.mul( const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date lastTransactionPoint.date
] ] ?? 1
); );
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
@ -659,7 +657,10 @@ export abstract class PortfolioCalculator {
return []; return [];
} }
const { endDate, startDate } = getInterval(dateRange, this.getStartDate()); const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
this.getStartDate()
);
const daysInMarket = differenceInDays(endDate, startDate) + 1; const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation const step = withDataDecimation

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

@ -7,7 +7,6 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } 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 { 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 { 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 { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -259,7 +259,7 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange); const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate, 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 { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; 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 { 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'; 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
@ -72,7 +72,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, uniq, uniqBy } from 'lodash'; import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { import {
@ -933,7 +933,7 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { endDate } = getInterval(dateRange); const { endDate } = getIntervalFromDateRange(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate, endDate,
@ -1115,7 +1115,7 @@ export class PortfolioService {
) )
); );
const { endDate, startDate } = getInterval(dateRange); const { endDate, startDate } = getIntervalFromDateRange(dateRange);
console.time('------- PortfolioService.getPerformance - 2'); 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 { Type as ActivityType } from '@prisma/client';
import {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) { export function getFactor(activityType: ActivityType) {
let factor: number; let factor: number;
@ -30,61 +17,3 @@ export function getFactor(activityType: ActivityType) {
return factor; 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 { footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05); 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, colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView,
hasPermissionToReportDataGlitch: hasPermission( hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.reportDataGlitch permissions.reportDataGlitch
@ -262,10 +266,11 @@ export class AppComponent implements OnDestroy, OnInit {
hasPermissionToUpdateOrder: hasPermissionToUpdateOrder:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) && hasPermission(this.user?.permissions, permissions.updateOrder) &&
!user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '95vw' : '50rem',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -292,9 +297,9 @@ export class AppComponent implements OnDestroy, OnInit {
); );
if (isDarkTheme) { if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme'); this.document.body.classList.add('theme-dark');
} else { } else {
this.document.body.classList.remove('is-dark-theme'); this.document.body.classList.remove('theme-dark');
} }
this.document 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 { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service'; import { LanguageService } from './core/language.service';
import { GfNotificationModule } from './core/notification/notification.module';
export function NgxStripeFactory(): string { export function NgxStripeFactory(): string {
return environment.stripePublicKey; return environment.stripePublicKey;
@ -47,6 +48,7 @@ export function NgxStripeFactory(): string {
BrowserModule, BrowserModule,
GfHeaderModule, GfHeaderModule,
GfLogoComponent, GfLogoComponent,
GfNotificationModule,
GfSubscriptionInterstitialDialogModule, GfSubscriptionInterstitialDialogModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatAutocompleteModule, 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -66,6 +67,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>, public dialogRef: MatDialogRef<AccountDetailDialog>,
private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -92,6 +94,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchPortfolioPerformance(); this.fetchPortfolioPerformance();
} }
public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, createDialog: true }
});
this.dialogRef.close();
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -147,6 +157,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchActivities(); this.fetchActivities();
} }
public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
this.dialogRef.close();
}
private fetchAccount() { private fetchAccount() {
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)

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

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

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

@ -2,4 +2,5 @@ export interface AccountDetailDialogParams {
accountId: string; accountId: string;
deviceType: string; deviceType: string;
hasImpersonationId: boolean; 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 { getLocale } from '@ghostfolio/common/helper';
import { import {
@ -54,7 +56,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {} public constructor(
private notificationService: NotificationService,
private router: Router
) {}
public ngOnInit() {} public ngOnInit() {}
@ -97,13 +102,13 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
public onDeleteAccount(aId: string) { public onDeleteAccount(aId: string) {
const confirmation = confirm( this.notificationService.confirm({
$localize`Do you really want to delete this account?` confirmFn: () => {
); this.accountDeleted.emit(aId);
},
if (confirmation) { confirmType: ConfirmationDialogType.Warn,
this.accountDeleted.emit(aId); title: $localize`Do you really want to delete this account?`
} });
} }
public onOpenAccountDetailDialog(accountId: string) { 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 { .mat-toolbar {
background-color: var(--dark-background); 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 { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -141,6 +142,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private router: Router,
private userService: UserService private userService: UserService
) {} ) {}
@ -424,6 +426,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.tagInput.nativeElement.value = ''; this.tagInput.nativeElement.value = '';
} }
public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, createDialog: true }
});
this.dialogRef.close();
}
public onClose() { public onClose() {
this.dialogRef.close(); 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

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

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

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

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

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

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

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

@ -14,7 +14,7 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces'; import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -58,7 +58,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() isLoading = false; @Input() isLoading = false;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas; @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 { import {
getLocale, getLocale,
getNumberFormatDecimal, getNumberFormatDecimal,
@ -39,7 +40,7 @@ export class PortfolioPerformanceComponent implements OnChanges {
@ViewChild('value') value: ElementRef; @ViewChild('value') value: ElementRef;
public constructor() {} public constructor(private notificationService: NotificationService) {}
public ngOnChanges() { public ngOnChanges() {
this.precision = this.precision >= 0 ? this.precision : 2; this.precision = this.precision >= 0 ? this.precision : 2;
@ -74,12 +75,15 @@ export class PortfolioPerformanceComponent implements OnChanges {
} }
public onShowErrors() { public onShowErrors() {
const errorMessageParts = [$localize`Market data is delayed for`]; const errorMessageParts = [];
for (const error of this.errors) { for (const error of this.errors) {
errorMessageParts.push(`${error.symbol} (${error.dataSource})`); 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 { .icon-container {
background-color: rgba(var(--light-primary-text), 0.05); 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-button {
&.mat-mdc-radio-checked { &.mat-mdc-radio-checked {
background-color: rgba(var(--light-dividers)); 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; display: block;
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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%; height: 100%;
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<!-- <mat-option value="ca"
<mat-option value="ca" >Català (<ng-container i18n>Community</ng-container
>Català (<ng-container i18n>Community</ng-container >)</mat-option
>)</mat-option >
>
-->
} }
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh" <mat-option value="zh"
@ -103,12 +101,10 @@
>)</mat-option >)</mat-option
> >
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<!-- <mat-option value="pl"
<mat-option value="pl" >Polski (<ng-container i18n>Community</ng-container
>Polski (<ng-container i18n>Community</ng-container >)</mat-option
>)</mat-option >
>
-->
} }
<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >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)); 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 { ::ng-deep {
.svgMap-map-wrapper { .svgMap-map-wrapper {
.svgMap-country { .svgMap-country {

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

@ -1,16 +1,39 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { NotificationService } from './notification/notification.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class LayoutService { export class LayoutService {
public static readonly DEFAULT_NOTIFICATION_MAX_WIDTH = '50rem';
public static readonly DEFAULT_NOTIFICATION_WIDTH = '75vw';
public shouldReloadContent$: Observable<void>; public shouldReloadContent$: Observable<void>;
private shouldReloadSubject = new Subject<void>(); private shouldReloadSubject = new Subject<void>();
public constructor() { public constructor(
private deviceService: DeviceDetectorService,
private notificationService: NotificationService
) {
this.shouldReloadContent$ = this.shouldReloadSubject.asObservable(); 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() { 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); color: rgb(var(--light-primary-text));
} }

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

@ -3,6 +3,6 @@
display: block; display: block;
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); color: rgb(var(--light-primary-text));
.about-container { .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)); 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>{ data: <AccountDetailDialogParams>{
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType, 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', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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 { .button-container {
.mat-mdc-outlined-button { .mat-mdc-outlined-button {
background-color: var(--dark-background); 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)); 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 { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -63,14 +62,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { 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']) { } else if (params['editDialog']) {
if (this.dataSource && params['activityId']) { if (params['activityId']) {
const activity = this.dataSource.data.find(({ id }) => { this.dataService
return id === params['activityId']; .fetchActivity(params['activityId'])
}); .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((activity) => {
this.openUpdateActivityDialog(activity); this.openUpdateActivityDialog(activity);
});
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
@ -242,7 +251,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.fetchActivities(); this.fetchActivities();
} }
public onUpdateActivity(aActivity: OrderModel) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true } 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-mdc-dialog-content {
.mat-datepicker-input { .mat-datepicker-input {
&.mat-mdc-input-element:disabled { &.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 { .drop-area {
border-color: rgba( border-color: rgba(
var(--palette-foreground-divider-dark), var(--palette-foreground-divider-dark),

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

@ -12,6 +12,7 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -584,7 +585,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
data: <AccountDetailDialogParams>{ data: <AccountDetailDialogParams>{
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType, 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', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' 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 { .mat-mdc-progress-bar {
::ng-deep { ::ng-deep {
.mdc-linear-progress__buffer-bar { .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" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
/> />
</div> </div>
</div> </div>
@ -340,7 +339,6 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentTimelineChart" [isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
[savingsRate]="savingsRate" [savingsRate]="savingsRate"
/> />
</div> </div>
@ -377,7 +375,6 @@
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingDividendTimelineChart" [isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[range]="user?.settings?.dateRange"
/> />
</div> </div>
</div> </div>

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

@ -2,6 +2,6 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); 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)); 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 { .button-container {
.mat-mdc-outlined-button { .mat-mdc-outlined-button {
background-color: var(--dark-background); 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)); 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)); color: rgb(var(--light-primary-text));
.call-to-action { .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)); 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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)); color: rgb(var(--dark-primary-text));
} }
:host-context(.is-dark-theme) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); 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 { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.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 { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.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({ public fetchDividends({
filters, filters,
groupBy = 'month', 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); background: var(--dark-background);
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
@ -359,10 +359,6 @@ ngx-skeleton-loader {
.cdk-global-overlay-wrapper { .cdk-global-overlay-wrapper {
justify-content: center !important; justify-content: center !important;
} }
.cdk-overlay-pane {
max-width: 95vw !important;
}
} }
.cursor-default { .cursor-default {

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

@ -3,7 +3,7 @@
$dark-primary-text: rgba(black, 0.87); $dark-primary-text: rgba(black, 0.87);
$light-primary-text: white; $light-primary-text: white;
$mat-css-dark-theme-selector: '.is-dark-theme'; $mat-css-dark-theme-selector: '.theme-dark';
$gf-primary: ( $gf-primary: (
50: var(--gf-theme-primary-50), 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); @include mat.all-component-colors($gf-theme-dark);
} }

1
docker/docker-compose.yml

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

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

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

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

@ -6,7 +6,9 @@ const locales = {
ASSET_CLASS: $localize`Asset Class`, ASSET_CLASS: $localize`Asset Class`,
ASSET_SUB_CLASS: $localize`Asset Sub Class`, ASSET_SUB_CLASS: $localize`Asset Sub Class`,
BUY_AND_SELL_ACTIVITIES_TOOLTIP: $localize`Buy and sell`, BUY_AND_SELL_ACTIVITIES_TOOLTIP: $localize`Buy and sell`,
CANCEL: $localize`Cancel`,
CORE: $localize`Core`, 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_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_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source or Ghostfolio Basic 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`, TAG: $localize`Tag`,
YEAR: $localize`Year`, YEAR: $localize`Year`,
YEARS: $localize`Years`, YEARS: $localize`Years`,
YES: $localize`Yes`,
// Activity types // Activity types
BUY: $localize`Buy`, 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)); 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 { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition PortfolioPosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
@ -23,7 +27,7 @@ import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { differenceInDays } from 'date-fns'; import { differenceInDays, max } from 'date-fns';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -41,6 +45,7 @@ export class GfTreemapChartComponent
implements AfterViewInit, OnChanges, OnDestroy implements AfterViewInit, OnChanges, OnDestroy
{ {
@Input() cursor: string; @Input() cursor: string;
@Input() dateRange: DateRange;
@Input() holdings: PortfolioPosition[]; @Input() holdings: PortfolioPosition[];
@Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>(); @Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ -75,6 +80,8 @@ export class GfTreemapChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const { endDate, startDate } = getIntervalFromDateRange(this.dateRange);
const data: ChartConfiguration['data'] = <any>{ const data: ChartConfiguration['data'] = <any>{
datasets: [ datasets: [
{ {
@ -82,8 +89,11 @@ export class GfTreemapChartComponent
const annualizedNetPerformancePercentWithCurrencyEffect = const annualizedNetPerformancePercentWithCurrencyEffect =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
new Date(), endDate,
ctx.raw._data.dateOfFirstActivity max([
ctx.raw._data.dateOfFirstActivity ?? new Date(0),
startDate
])
), ),
netPerformancePercentage: new Big( netPerformancePercentage: new Big(
ctx.raw._data.netPerformancePercentWithCurrencyEffect ctx.raw._data.netPerformancePercentWithCurrencyEffect

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

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

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

Loading…
Cancel
Save