diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index e1f994749..5c072d760 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node_version: - - 18 + - 20 steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,16 +24,16 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node_version }} - cache: 'yarn' + cache: 'npm' - name: Install dependencies - run: yarn install --frozen-lockfile + run: npm ci - name: Check formatting - run: yarn format:check + run: npm run format:check - name: Execute tests - run: yarn test + run: npm test - name: Build application - run: yarn build:production + run: npm run build:production diff --git a/.gitignore b/.gitignore index 0795e4068..d7e5e5eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ /tmp # dependencies -/.yarn /node_modules +npm-debug.log # IDEs and editors /.idea @@ -34,10 +34,8 @@ /coverage /dist /libpeerconnection.log -npm-debug.log testem.log /typings -yarn-error.log # System Files .DS_Store diff --git a/.nvmrc b/.nvmrc index 3f430af82..9a2a0e219 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18 +v20 diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 788570fcd..000000000 --- a/.yarnrc +++ /dev/null @@ -1 +0,0 @@ -network-timeout 600000 diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8c5f80d..a6e3b0ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,80 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `Nx` from version `19.5.1` to `19.5.6` + +## 2.101.0 - 2024-08-03 + +### Changed + +- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities + +## 2.100.0 - 2024-08-03 + +### Added + +- Added support to manage tags of holdings in the holding detail dialog + +### Changed + +- Improved the color assignment in the chart of the holdings tab on the home page (experimental) +- Persisted the view mode of the holdings tab on the home page (experimental) +- Improved the language localization for Catalan (`ca`) +- Improved the language localization for Spanish (`es`) + +## 2.99.0 - 2024-07-29 + +### Changed + +- Migrated the usage of `yarn` to `npm` +- Upgraded `storybook` from version `7.0.9` to `8.2.5` +- Downgraded `marked` from version `13.0.0` to `12.0.2` + +## 2.98.0 - 2024-07-27 + +### Added + +- Set up the language localization for Catalan (`ca`) + +### Changed + +- Improved the account selector of the create or update activity dialog +- Improved the handling of the numerical precision in the value component +- Skipped derived currencies in the get quotes functionality of the data provider service +- Improved the language localization for Spanish (`es`) +- Upgraded `angular` from version `18.0.4` to `18.1.1` +- Upgraded `Nx` from version `19.4.3` to `19.5.1` +- Upgraded `prisma` from version `5.16.1` to `5.17.0` + +### Fixed + +- Fixed the dividend import from a data provider for holdings without an account +- Fixed an issue in the public page related to a non-existent access + +## 2.97.0 - 2024-07-20 + +### Added + +- Added _selfh.st_ to the _As seen in_ section on the landing page + +### Changed + +- Improved the numerical precision in the holding detail dialog +- Improved the handling of the numerical precision in the value component +- Optimized the 7d data gathering by prioritizing the currencies +- Improved the language localization for German (`de`) +- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`) +- Upgraded `Nx` from version `19.4.0` to `19.4.3` +- Upgraded `prettier` from version `3.3.1` to `3.3.3` + +### Fixed + +- Fixed the table sorting of the holdings tab on the home page + ## 2.96.0 - 2024-07-13 ### Changed @@ -4812,7 +4886,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the attribute `precision` in the value component +- Added the attribute `precision` to the value component ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 19687e8dd..859f28aae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template #### Upgrade -1. Run `yarn nx migrate latest` -1. Make sure `package.json` changes make sense and then run `yarn install` -1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338) +1. Run `npx nx migrate latest` +1. Make sure `package.json` changes make sense and then run `npm install` +1. Run `npx nx migrate --run-migrations` ### Prisma #### Access database via GUI -Run `yarn database:gui` +Run `npm run database:gui` https://www.prisma.io/studio #### Synchronize schema with database for prototyping -Run `yarn database:push` +Run `npm run database:push` https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push #### Create schema migration -Run `yarn prisma migrate dev --name added_job_title` +Run `npm run prisma migrate dev --name added_job_title` https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate diff --git a/Dockerfile b/Dockerfile index d06c11cc4..8ca86a308 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM node:18-slim as builder +FROM --platform=$BUILDPLATFORM node:20-slim AS builder # Build application and add additional files WORKDIR /ghostfolio @@ -8,18 +8,17 @@ WORKDIR /ghostfolio COPY ./CHANGELOG.md CHANGELOG.md COPY ./LICENSE LICENSE COPY ./package.json package.json -COPY ./yarn.lock yarn.lock -COPY ./.yarnrc .yarnrc +COPY ./package-lock.json package-lock.json COPY ./prisma/schema.prisma prisma/schema.prisma -RUN apt update && apt install -y \ - g++ \ - git \ - make \ - openssl \ - python3 \ - && rm -rf /var/lib/apt/lists/* -RUN yarn install +RUN apt-get update && apt-get install -y --no-install-suggests \ + g++ \ + git \ + make \ + openssl \ + python3 \ + && rm -rf /var/lib/apt/lists/* +RUN npm install # See https://github.com/nrwl/nx/issues/6586 for further details COPY ./decorate-angular-cli.js decorate-angular-cli.js @@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json COPY ./libs libs COPY ./apps apps -RUN yarn build:production +RUN npm run build:production # Prepare the dist image with additional node_modules WORKDIR /ghostfolio/dist/apps/api # package.json was generated by the build process, however the original -# yarn.lock needs to be used to ensure the same versions -COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock +# package-lock.json needs to be used to ensure the same versions +COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json -RUN yarn +RUN npm install COPY prisma /ghostfolio/dist/apps/api/prisma # Overwrite the generated package.json with the original one to ensure having # all the scripts COPY package.json /ghostfolio/dist/apps/api -RUN yarn database:generate-typings +RUN npm run database:generate-typings # Image to run, copy everything needed from builder -FROM node:18-slim - +FROM node:20-slim LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" +ENV NODE_ENV=production -RUN apt update && apt install -y \ - curl \ - openssl \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-suggests \ + curl \ + openssl \ + && rm -rf /var/lib/apt/lists/* COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh +RUN chown -R node:node /ghostfolio WORKDIR /ghostfolio/apps/api EXPOSE ${PORT:-3333} +USER node CMD [ "/ghostfolio/entrypoint.sh" ] diff --git a/README.md b/README.md index 42c8220fc..6d31b96ba 100644 --- a/README.md +++ b/README.md @@ -149,16 +149,15 @@ Ghostfolio is available for various home server systems, including [CasaOS](http ### Prerequisites - [Docker](https://www.docker.com/products/docker-desktop) -- [Node.js](https://nodejs.org/en/download) (version 18+) -- [Yarn](https://yarnpkg.com/en/docs/install) +- [Node.js](https://nodejs.org/en/download) (version 20+) - Create a local copy of this Git repository (clone) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) ### Setup -1. Run `yarn install` +1. Run `npm install` 1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) -1. Run `yarn database:setup` to initialize the database schema +1. Run `npm run database:setup` to initialize the database schema 1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks 1. Start the server and the client (see [_Development_](#Development)) 1. Open https://localhost:4200/en in your browser @@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http #### Debug -Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com) +Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com) #### Serve -Run `yarn start:server` +Run `npm run start:server` ### Start Client -Run `yarn start:client` and open https://localhost:4200/en in your browser +Run `npm run start:client` and open https://localhost:4200/en in your browser ### Start _Storybook_ -Run `yarn start:storybook` +Run `npm run start:storybook` ### Migrate Database With the following command you can keep your database schema in sync: ```bash -yarn database:push +npm run database:push ``` ## Testing -Run `yarn test` +Run `npm test` ## Public API diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 4494fef7a..69e6955c1 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -81,10 +81,11 @@ export class AdminController { @Post('gather/max') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherMax(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, @@ -107,10 +108,11 @@ export class AdminController { @Post('gather/profile-data') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherProfileData(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 3d81435ab..50b781f54 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -21,18 +21,19 @@ import { AdminMarketData, AdminMarketDataDetails, AdminMarketDataItem, + AssetProfileIdentifier, EnhancedSymbolProfile, - Filter, - UniqueAsset + Filter } from '@ghostfolio/common/interfaces'; import { MarketDataPreset } from '@ghostfolio/common/types'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { AssetClass, AssetSubClass, DataSource, Prisma, + PrismaClient, Property, SymbolProfile } from '@prisma/client'; @@ -58,7 +59,9 @@ export class AdminService { currency, dataSource, symbol - }: UniqueAsset & { currency?: string }): Promise { + }: AssetProfileIdentifier & { currency?: string }): Promise< + SymbolProfile | never + > { try { if (dataSource === 'MANUAL') { return this.symbolProfileService.add({ @@ -95,7 +98,10 @@ export class AdminService { } } - public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { + public async deleteProfileData({ + dataSource, + symbol + }: AssetProfileIdentifier) { await this.marketDataService.deleteMany({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol }); } @@ -212,104 +218,119 @@ export class AdminService { } } - let [assetProfiles, count] = await Promise.all([ - this.prismaService.symbolProfile.findMany({ - orderBy, - skip, - take, - where, - select: { - _count: { - select: { Order: true } - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - id: true, - name: true, - Order: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - scraperConfiguration: true, - sectors: true, - symbol: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); + const extendedPrismaClient = this.getExtendedPrismaClient(); - let marketData: AdminMarketDataItem[] = assetProfiles.map( - ({ - _count, - assetClass, - assetSubClass, - comment, - countries, - currency, - dataSource, - id, - name, - Order, - sectors, - symbol - }) => { - const countriesCount = countries ? Object.keys(countries).length : 0; - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - const sectorsCount = sectors ? Object.keys(sectors).length : 0; + try { + let [assetProfiles, count] = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + orderBy, + skip, + take, + where, + select: { + _count: { + select: { Order: true } + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isUsedByUsersWithSubscription: true, + name: true, + Order: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + scraperConfiguration: true, + sectors: true, + symbol: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); - return { - assetClass, - assetSubClass, - comment, - currency, - countriesCount, - dataSource, - id, - name, - symbol, - marketDataItemCount, - sectorsCount, - activitiesCount: _count.Order, - date: Order?.[0]?.date - }; - } - ); + let marketData: AdminMarketDataItem[] = await Promise.all( + assetProfiles.map( + async ({ + _count, + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource, + id, + isUsedByUsersWithSubscription, + name, + Order, + sectors, + symbol + }) => { + const countriesCount = countries + ? Object.keys(countries).length + : 0; + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + const sectorsCount = sectors ? Object.keys(sectors).length : 0; + + return { + assetClass, + assetSubClass, + comment, + currency, + countriesCount, + dataSource, + id, + name, + symbol, + marketDataItemCount, + sectorsCount, + activitiesCount: _count.Order, + date: Order?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription + }; + } + ) + ); - if (presetId) { - if (presetId === 'ETF_WITHOUT_COUNTRIES') { - marketData = marketData.filter(({ countriesCount }) => { - return countriesCount === 0; - }); - } else if (presetId === 'ETF_WITHOUT_SECTORS') { - marketData = marketData.filter(({ sectorsCount }) => { - return sectorsCount === 0; - }); + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + marketData = marketData.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + marketData = marketData.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = marketData.length; } - count = marketData.length; - } + return { + count, + marketData + }; + } finally { + await extendedPrismaClient.$disconnect(); - return { - count, - marketData - }; + Logger.debug('Disconnect extended prisma client', 'AdminService'); + } } public async getMarketDataBySymbol({ dataSource, symbol - }: UniqueAsset): Promise { + }: AssetProfileIdentifier): Promise { let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; let currency: EnhancedSymbolProfile['currency'] = '-'; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; @@ -370,7 +391,7 @@ export class AdminService { symbol, symbolMapping, url - }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { + }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { const symbolProfileOverrides = { assetClass: assetClass as AssetClass, assetSubClass: assetSubClass as AssetSubClass, @@ -378,28 +399,28 @@ export class AdminService { url: url as string }; - const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset = - { - comment, - countries, - currency, - dataSource, - holdings, - scraperConfiguration, - sectors, - symbol, - symbolMapping, - ...(dataSource === 'MANUAL' - ? { assetClass, assetSubClass, name, url } - : { - SymbolProfileOverrides: { - upsert: { - create: symbolProfileOverrides, - update: symbolProfileOverrides - } + const updatedSymbolProfile: AssetProfileIdentifier & + Prisma.SymbolProfileUpdateInput = { + comment, + countries, + currency, + dataSource, + holdings, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + ...(dataSource === 'MANUAL' + ? { assetClass, assetSubClass, name, url } + : { + SymbolProfileOverrides: { + upsert: { + create: symbolProfileOverrides, + update: symbolProfileOverrides } - }) - }; + } + }) + }; await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile); @@ -431,6 +452,52 @@ export class AdminService { return response; } + private getExtendedPrismaClient() { + Logger.debug('Connect extended prisma client', 'AdminService'); + + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + Order: { + where: { + User: { + Subscription: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.Order > 0; + } + } + } + } + }); + }); + + return new PrismaClient().$extends(symbolProfileExtension); + } + private async getMarketDataForCurrencies(): Promise { const marketDataItems = await this.prismaService.marketData.groupBy({ _count: true, diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index 9c6331498..ea9ba8025 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -4,9 +4,9 @@ import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import type { + AssetProfileIdentifier, BenchmarkMarketDataDetails, - BenchmarkResponse, - UniqueAsset + BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; @@ -41,7 +41,9 @@ export class BenchmarkController { @HasPermission(permissions.accessAdminControl) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { + public async addBenchmark( + @Body() { dataSource, symbol }: AssetProfileIdentifier + ) { try { const benchmark = await this.benchmarkService.addBenchmark({ dataSource, diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 27d91fd7d..e9495b44b 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -17,11 +17,11 @@ import { resetHours } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, Benchmark, BenchmarkMarketDataDetails, BenchmarkProperty, - BenchmarkResponse, - UniqueAsset + BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { BenchmarkTrend } from '@ghostfolio/common/types'; @@ -61,7 +61,10 @@ export class BenchmarkService { return 0; } - public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) { + public async getBenchmarkTrends({ + dataSource, + symbol + }: AssetProfileIdentifier) { const historicalData = await this.marketDataService.marketDataItems({ orderBy: { date: 'desc' @@ -228,7 +231,7 @@ export class BenchmarkService { endDate?: Date; startDate: Date; userCurrency: string; - } & UniqueAsset): Promise { + } & AssetProfileIdentifier): Promise { const marketData: { date: string; value: number }[] = []; const days = differenceInDays(endDate, startDate) + 1; @@ -348,7 +351,7 @@ export class BenchmarkService { public async addBenchmark({ dataSource, symbol - }: UniqueAsset): Promise> { + }: AssetProfileIdentifier): Promise> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -385,7 +388,7 @@ export class BenchmarkService { public async deleteBenchmark({ dataSource, symbol - }: UniqueAsset): Promise> { + }: AssetProfileIdentifier): Promise> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 9b8668158..69e64387d 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -19,7 +19,7 @@ import { getAssetProfileIdentifier, parseDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AccountWithPlatform, OrderWithAccount, @@ -51,7 +51,7 @@ export class ImportService { dataSource, symbol, userCurrency - }: UniqueAsset & { userCurrency: string }): Promise { + }: AssetProfileIdentifier & { userCurrency: string }): Promise { try { const { firstBuyDate, historicalData, orders } = await this.portfolioService.getPosition(dataSource, undefined, symbol); @@ -72,9 +72,13 @@ export class ImportService { }) ]); - const accounts = orders.map((order) => { - return order.Account; - }); + const accounts = orders + .filter(({ Account }) => { + return !!Account; + }) + .map(({ Account }) => { + return Account; + }); const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index a944900a0..908921a19 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,6 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { HttpException, Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -17,7 +17,7 @@ export class LogoService { public async getLogoByDataSourceAndSymbol({ dataSource, symbol - }: UniqueAsset) { + }: AssetProfileIdentifier) { if (!DataSource[dataSource]) { throw new HttpException( getReasonPhrase(StatusCodes.NOT_FOUND), diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 5d8e1f51b..7bf07a415 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -11,9 +11,9 @@ import { } from '@ghostfolio/common/config'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, EnhancedSymbolProfile, - Filter, - UniqueAsset + Filter } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; @@ -46,6 +46,39 @@ export class OrderService { private readonly symbolProfileService: SymbolProfileService ) {} + public async assignTags({ + dataSource, + symbol, + tags, + userId + }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { + const orders = await this.prismaService.order.findMany({ + where: { + userId, + SymbolProfile: { + dataSource, + symbol + } + } + }); + + return Promise.all( + orders.map(({ id }) => + this.prismaService.order.update({ + data: { + tags: { + // The set operation replaces all existing connections with the provided ones + set: tags.map(({ id }) => { + return { id }; + }) + } + }, + where: { id } + }) + ) + ); + } + public async createOrder( data: Prisma.OrderCreateInput & { accountId?: string; @@ -252,7 +285,7 @@ export class OrderService { return count; } - public async getLatestOrder({ dataSource, symbol }: UniqueAsset) { + public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.order.findFirst({ orderBy: { date: 'desc' @@ -432,7 +465,7 @@ export class OrderService { this.prismaService.order.count({ where }) ]); - const uniqueAssets = uniqBy( + const assetProfileIdentifiers = uniqBy( orders.map(({ SymbolProfile }) => { return { dataSource: SymbolProfile.dataSource, @@ -447,8 +480,9 @@ export class OrderService { } ); - const assetProfiles = - await this.symbolProfileService.getSymbolProfiles(uniqueAssets); + const assetProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); const activities = orders.map((order) => { const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index 5d168b619..1b142d8b3 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -1,5 +1,8 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; -import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; export class MWRPortfolioCalculator extends PortfolioCalculator { @@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator { }; start: Date; step?: number; - } & UniqueAsset): SymbolMetrics { + } & AssetProfileIdentifier): SymbolMetrics { throw new Error('Method not implemented.'); } } diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index f70f5ec3d..d6e5d8bd9 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -19,12 +19,12 @@ import { resetHours } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, DataProviderInfo, HistoricalDataItem, InvestmentItem, ResponseError, - SymbolMetrics, - UniqueAsset + SymbolMetrics } from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { DateRange, GroupBy } from '@ghostfolio/common/types'; @@ -447,15 +447,15 @@ export abstract class PortfolioCalculator { dataSource: item.dataSource, fee: item.fee, firstBuyDate: item.firstBuyDate, - grossPerformance: !hasErrors ? grossPerformance ?? null : null, + grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformancePercentage: !hasErrors - ? grossPerformancePercentage ?? null + ? (grossPerformancePercentage ?? null) : null, grossPerformancePercentageWithCurrencyEffect: !hasErrors - ? grossPerformancePercentageWithCurrencyEffect ?? null + ? (grossPerformancePercentageWithCurrencyEffect ?? null) : null, grossPerformanceWithCurrencyEffect: !hasErrors - ? grossPerformanceWithCurrencyEffect ?? null + ? (grossPerformanceWithCurrencyEffect ?? null) : null, investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, @@ -463,15 +463,15 @@ export abstract class PortfolioCalculator { marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? null, - netPerformance: !hasErrors ? netPerformance ?? null : null, + netPerformance: !hasErrors ? (netPerformance ?? null) : null, netPerformancePercentage: !hasErrors - ? netPerformancePercentage ?? null + ? (netPerformancePercentage ?? null) : null, netPerformancePercentageWithCurrencyEffect: !hasErrors - ? netPerformancePercentageWithCurrencyEffect ?? null + ? (netPerformancePercentageWithCurrencyEffect ?? null) : null, netPerformanceWithCurrencyEffect: !hasErrors - ? netPerformanceWithCurrencyEffect ?? null + ? (netPerformanceWithCurrencyEffect ?? null) : null, quantity: item.quantity, symbol: item.symbol, @@ -1213,7 +1213,7 @@ export abstract class PortfolioCalculator { }; start: Date; step?: number; - } & UniqueAsset): SymbolMetrics; + } & AssetProfileIdentifier): SymbolMetrics; public getTransactionPoints() { return this.transactionPoints; diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 8035faa20..53a4ec826 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -2,7 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; -import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { Logger } from '@nestjs/common'; @@ -157,7 +160,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { }; start: Date; step?: number; - } & UniqueAsset): SymbolMetrics { + } & AssetProfileIdentifier): SymbolMetrics { const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentValues: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 9b0548522..c86dde448 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -1,7 +1,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DataSource, MarketData } from '@prisma/client'; @@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { }); }, getRange: ({ + assetProfileIdentifiers, dateRangeEnd, - dateRangeStart, - uniqueAssets + dateRangeStart }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; dateRangeEnd: Date; dateRangeStart: Date; - uniqueAssets: UniqueAsset[]; }) => { return Promise.resolve([ { createdAt: dateRangeStart, - dataSource: uniqueAssets[0].dataSource, + dataSource: assetProfileIdentifiers[0].dataSource, date: dateRangeStart, id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', marketPrice: 1841.823902, state: 'CLOSE', - symbol: uniqueAssets[0].symbol + symbol: assetProfileIdentifiers[0].symbol }, { createdAt: dateRangeEnd, - dataSource: uniqueAssets[0].dataSource, + dataSource: assetProfileIdentifiers[0].dataSource, date: dateRangeEnd, id: '082d6893-df27-4c91-8a5d-092e84315b56', marketPrice: 1847.839966, state: 'CLOSE', - symbol: uniqueAssets[0].symbol + symbol: assetProfileIdentifiers[0].symbol } ]); } diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 712d07e7a..24119162d 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -3,9 +3,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { resetHours } from '@ghostfolio/common/helper'; import { + AssetProfileIdentifier, DataProviderInfo, - ResponseError, - UniqueAsset + ResponseError } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -80,17 +80,16 @@ export class CurrentRateService { ); } - const uniqueAssets: UniqueAsset[] = dataGatheringItems.map( - ({ dataSource, symbol }) => { + const assetProfileIdentifiers: AssetProfileIdentifier[] = + dataGatheringItems.map(({ dataSource, symbol }) => { return { dataSource, symbol }; - } - ); + }); promises.push( this.marketDataService .getRange({ - dateQuery, - uniqueAssets + assetProfileIdentifiers, + dateQuery }) .then((data) => { return data.map(({ dataSource, date, marketPrice, symbol }) => { diff --git a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts index 6c42d260c..34b426693 100644 --- a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts @@ -1,6 +1,6 @@ -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -export interface GetValueObject extends UniqueAsset { +export interface GetValueObject extends AssetProfileIdentifier { date: Date; marketPrice: number; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 509b766f0..819cf13af 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,6 +1,7 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, @@ -29,7 +30,8 @@ import { } from '@ghostfolio/common/interfaces'; import { hasReadRestrictedAccessPermission, - isRestrictedView + isRestrictedView, + permissions } from '@ghostfolio/common/permissions'; import type { DateRange, @@ -38,12 +40,14 @@ import type { } from '@ghostfolio/common/types'; import { + Body, Controller, Get, Headers, HttpException, Inject, Param, + Put, Query, UseGuards, UseInterceptors, @@ -51,12 +55,13 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { AssetClass, AssetSubClass } from '@prisma/client'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioService } from './portfolio.service'; +import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @Controller('portfolio') export class PortfolioController { @@ -508,9 +513,6 @@ export class PortfolioController { @Param('accessId') accessId ): Promise { const access = await this.accessService.access({ id: accessId }); - const user = await this.userService.user({ - id: access.userId - }); if (!access) { throw new HttpException( @@ -520,6 +522,11 @@ export class PortfolioController { } let hasDetails = true; + + const user = await this.userService.user({ + id: access.userId + }); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { hasDetails = user.subscription.type === 'Premium'; } @@ -576,23 +583,23 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getPosition( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, - @Param('dataSource') dataSource, - @Param('symbol') symbol + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string ): Promise { - const position = await this.portfolioService.getPosition( + const holding = await this.portfolioService.getPosition( dataSource, impersonationId, symbol ); - if (position) { - return position; + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); } - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); + return holding; } @Get('report') @@ -615,4 +622,36 @@ export class PortfolioController { return report; } + + @HasPermission(permissions.updateOrder) + @Put('position/:dataSource/:symbol/tags') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateHoldingTags( + @Body() data: UpdateHoldingTagsDto, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getPosition( + dataSource, + impersonationId, + symbol + ); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + await this.portfolioService.updateTags({ + dataSource, + impersonationId, + symbol, + tags: data.tags, + userId: this.request.user.id + }); + } } diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts deleted file mode 100644 index 92970f547..000000000 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Big } from 'big.js'; - -import { PortfolioService } from './portfolio.service'; - -describe('PortfolioService', () => { - let portfolioService: PortfolioService; - - beforeAll(async () => { - portfolioService = new PortfolioService( - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ); - }); - - describe('annualized performance percentage', () => { - it('Get annualized performance', async () => { - expect( - portfolioService - .getAnnualizedPerformancePercent({ - daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day - netPerformancePercentage: new Big(0) - }) - .toNumber() - ).toEqual(0); - - expect( - portfolioService - .getAnnualizedPerformancePercent({ - daysInMarket: 0, - netPerformancePercentage: new Big(0) - }) - .toNumber() - ).toEqual(0); - - /** - * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html - */ - expect( - portfolioService - .getAnnualizedPerformancePercent({ - daysInMarket: 65, // < 1 year - netPerformancePercentage: new Big(0.1025) - }) - .toNumber() - ).toBeCloseTo(0.729705); - - expect( - portfolioService - .getAnnualizedPerformancePercent({ - daysInMarket: 365, // 1 year - netPerformancePercentage: new Big(0.05) - }) - .toNumber() - ).toBeCloseTo(0.05); - - /** - * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation - */ - expect( - portfolioService - .getAnnualizedPerformancePercent({ - daysInMarket: 575, // > 1 year - netPerformancePercentage: new Big(0.2374) - }) - .toNumber() - ).toBeCloseTo(0.145); - }); - }); -}); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 12a584d3f..1f21fa728 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, EMERGENCY_FUND_TAG_ID, @@ -58,7 +59,8 @@ import { DataSource, Order, Platform, - Prisma + Prisma, + Tag } from '@prisma/client'; import { Big } from 'big.js'; import { @@ -70,7 +72,7 @@ import { parseISO, set } from 'date-fns'; -import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; +import { isEmpty, uniq, uniqBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { @@ -206,24 +208,6 @@ export class PortfolioService { }; } - public getAnnualizedPerformancePercent({ - daysInMarket, - netPerformancePercentage - }: { - daysInMarket: number; - netPerformancePercentage: Big; - }): Big { - if (isNumber(daysInMarket) && daysInMarket > 0) { - const exponent = new Big(365).div(daysInMarket).toNumber(); - - return new Big( - Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent) - ).minus(1); - } - - return new Big(0); - } - public async getDividends({ activities, groupBy @@ -734,7 +718,7 @@ export class PortfolioService { return Account; }); - const dividendYieldPercent = this.getAnnualizedPerformancePercent({ + const dividendYieldPercent = getAnnualizedPerformancePercent({ daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), netPerformancePercentage: timeWeightedInvestment.eq(0) ? new Big(0) @@ -742,7 +726,7 @@ export class PortfolioService { }); const dividendYieldPercentWithCurrencyEffect = - this.getAnnualizedPerformancePercent({ + getAnnualizedPerformancePercent({ daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( 0 @@ -1329,6 +1313,24 @@ export class PortfolioService { }; } + public async updateTags({ + dataSource, + impersonationId, + symbol, + tags, + userId + }: { + dataSource: DataSource; + impersonationId: string; + symbol: string; + tags: Tag[]; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + + await this.orderService.assignTags({ dataSource, symbol, tags, userId }); + } + private async getCashPositions({ cashDetails, userCurrency, @@ -1746,13 +1748,13 @@ export class PortfolioService { const daysInMarket = differenceInDays(new Date(), firstOrderDate); - const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ + const annualizedPerformancePercent = getAnnualizedPerformancePercent({ daysInMarket, netPerformancePercentage: new Big(netPerformancePercentage) })?.toNumber(); const annualizedPerformancePercentWithCurrencyEffect = - this.getAnnualizedPerformancePercent({ + getAnnualizedPerformancePercent({ daysInMarket, netPerformancePercentage: new Big( netPerformancePercentageWithCurrencyEffect diff --git a/apps/api/src/app/portfolio/update-holding-tags.dto.ts b/apps/api/src/app/portfolio/update-holding-tags.dto.ts new file mode 100644 index 000000000..11efe189d --- /dev/null +++ b/apps/api/src/app/portfolio/update-holding-tags.dto.ts @@ -0,0 +1,7 @@ +import { Tag } from '@prisma/client'; +import { IsArray } from 'class-validator'; + +export class UpdateHoldingTagsDto { + @IsArray() + tags: Tag[]; +} diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index 53b177b4f..de41220b9 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -1,6 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -28,7 +28,7 @@ export class RedisCacheService { return `portfolio-snapshot-${userId}`; } - public getQuoteKey({ dataSource, symbol }: UniqueAsset) { + public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) { return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; } diff --git a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts index 358658672..710a84144 100644 --- a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts +++ b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts @@ -1,6 +1,9 @@ -import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; -export interface SymbolItem extends UniqueAsset { +export interface SymbolItem extends AssetProfileIdentifier { currency: string; historicalData: HistoricalDataItem[]; marketPrice: number; diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 90259a776..2baca18dd 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -40,13 +40,13 @@ export class SymbolService { const days = includeHistoricalData; const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: subDays(new Date(), days) }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource: dataGatheringItem.dataSource, symbol: dataGatheringItem.symbol } - ] + ], + dateQuery: { gte: subDays(new Date(), days) } }); historicalData = marketData.map(({ date, marketPrice: value }) => { diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 1fc02ff4d..78e6c27a9 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import type { ColorScheme, DateRange, + HoldingsViewMode, ViewMode } from '@ghostfolio/common/types'; @@ -66,6 +67,10 @@ export class UpdateUserSettingDto { @IsOptional() 'filters.tags'?: string[]; + @IsIn(['CHART', 'TABLE']) + @IsOptional() + holdingsViewMode?: HoldingsViewMode; + @IsBoolean() @IsOptional() isExperimentalFeatures?: boolean; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 02a65b6a0..7aa1dbbe8 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -190,7 +190,7 @@ export class UserService { (user.Settings.settings as UserSettings).dateRange = (user.Settings.settings as UserSettings).viewMode === 'ZEN' ? 'max' - : (user.Settings.settings as UserSettings)?.dateRange ?? 'max'; + : ((user.Settings.settings as UserSettings)?.dateRange ?? 'max'); // Set default value for view mode if (!(user.Settings.settings as UserSettings).viewMode) { @@ -243,6 +243,9 @@ export class UserService { // Reset benchmark user.Settings.settings.benchmark = undefined; + + // Reset holdings view mode + user.Settings.settings.holdingsViewMode = undefined; } else if (user.subscription?.type === 'Premium') { currentPermissions.push(permissions.reportDataGlitch); diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index 7510b180e..860024c61 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -4,6 +4,12 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> + https://ghostfol.io/de ${currentDate}T00:00:00+00:00 @@ -441,10 +447,10 @@ ${currentDate}T00:00:00+00:00 https://ghostfol.io/pt diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index fc5d613a2..864891c6a 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -45,10 +45,11 @@ export class CronService { @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) public async runEverySundayAtTwelvePm() { if (await this.isDataGatheringEnabled()) { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, diff --git a/apps/api/src/services/data-gathering/data-gathering.processor.ts b/apps/api/src/services/data-gathering/data-gathering.processor.ts index 11eda2e7a..d8a6a7644 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -7,7 +7,7 @@ import { GATHER_HISTORICAL_MARKET_DATA_PROCESS } from '@ghostfolio/common/config'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Process, Processor } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; @@ -35,7 +35,7 @@ export class DataGatheringProcessor { ) {} @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) - public async gatherAssetProfile(job: Job) { + public async gatherAssetProfile(job: Job) { try { Logger.log( `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index a80d68d6b..8b8c65a21 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -10,6 +10,7 @@ import { DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_LOW, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, PROPERTY_BENCHMARKS @@ -19,7 +20,10 @@ import { getAssetProfileIdentifier, resetHours } from '@ghostfolio/common/helper'; -import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + BenchmarkProperty +} from '@ghostfolio/common/interfaces'; import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -62,9 +66,22 @@ export class DataGatheringService { } public async gather7Days() { - const dataGatheringItems = await this.getSymbols7D(); await this.gatherSymbols({ - dataGatheringItems, + dataGatheringItems: await this.getCurrencies7D(), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: true + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: false + }), priority: DATA_GATHERING_QUEUE_PRIORITY_LOW }); } @@ -77,7 +94,7 @@ export class DataGatheringService { }); } - public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { + public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { await this.marketDataService.deleteMany({ dataSource, symbol }); const dataGatheringItems = (await this.getSymbolsMax()).filter( @@ -132,23 +149,29 @@ export class DataGatheringService { } } - public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) { - let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => { - return dataGatheringItem.dataSource !== 'MANUAL'; - }); + public async gatherAssetProfiles( + aAssetProfileIdentifiers?: AssetProfileIdentifier[] + ) { + let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter( + (dataGatheringItem) => { + return dataGatheringItem.dataSource !== 'MANUAL'; + } + ); - if (!uniqueAssets) { - uniqueAssets = await this.getUniqueAssets(); + if (!assetProfileIdentifiers) { + assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers(); } - if (uniqueAssets.length <= 0) { + if (assetProfileIdentifiers.length <= 0) { return; } - const assetProfiles = - await this.dataProviderService.getAssetProfiles(uniqueAssets); - const symbolProfiles = - await this.symbolProfileService.getSymbolProfiles(uniqueAssets); + const assetProfiles = await this.dataProviderService.getAssetProfiles( + assetProfileIdentifiers + ); + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { const symbolMapping = symbolProfiles.find((symbolProfile) => { @@ -234,7 +257,7 @@ export class DataGatheringService { 'DataGatheringService' ); - if (uniqueAssets.length === 1) { + if (assetProfileIdentifiers.length === 1) { throw error; } } @@ -270,7 +293,9 @@ export class DataGatheringService { ); } - public async getUniqueAssets(): Promise { + public async getAllAssetProfileIdentifiers(): Promise< + AssetProfileIdentifier[] + > { const symbolProfiles = await this.prismaService.symbolProfile.findMany({ orderBy: [{ symbol: 'asc' }] }); @@ -290,73 +315,83 @@ export class DataGatheringService { }); } - private getEarliestDate(aStartDate: Date) { - return min([aStartDate, subYears(new Date(), 10)]); - } - - private async getSymbols7D(): Promise { - const startDate = subDays(resetHours(new Date()), 7); - - const symbolProfiles = await this.prismaService.symbolProfile.findMany({ - orderBy: [{ symbol: 'asc' }], - select: { - dataSource: true, - scraperConfiguration: true, - symbol: true - } - }); - - // Only consider symbols with incomplete market data for the last - // 7 days - const symbolsWithCompleteMarketData = ( + private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< + AssetProfileIdentifier[] + > { + return ( await this.prismaService.marketData.groupBy({ _count: true, - by: ['symbol'], + by: ['dataSource', 'symbol'], orderBy: [{ symbol: 'asc' }], where: { - date: { gt: startDate }, + date: { gt: subDays(resetHours(new Date()), 7) }, state: 'CLOSE' } }) ) - .filter((group) => { - return group._count >= 6; + .filter(({ _count }) => { + return _count >= 6; + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }); + } + + private async getCurrencies7D(): Promise { + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return this.exchangeRateDataService + .getCurrencyPairs() + .filter(({ dataSource, symbol }) => { + return !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); }) - .map((group) => { - return group.symbol; + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: subDays(resetHours(new Date()), 7) + }; }); + } + + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } - const symbolProfilesToGather = symbolProfiles + private async getSymbols7D({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }): Promise { + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesByUserSubscription({ + withUserSubscription + }); + + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return symbolProfiles .filter(({ dataSource, scraperConfiguration, symbol }) => { const manualDataSourceWithScraperConfiguration = dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); return ( - !symbolsWithCompleteMarketData.includes(symbol) && + !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }) && (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) ); }) .map((symbolProfile) => { return { ...symbolProfile, - date: startDate + date: subDays(resetHours(new Date()), 7) }; }); - - const currencyPairsToGather = this.exchangeRateDataService - .getCurrencyPairs() - .filter(({ symbol }) => { - return !symbolsWithCompleteMarketData.includes(symbol); - }) - .map(({ dataSource, symbol }) => { - return { - dataSource, - symbol, - date: startDate - }; - }); - - return [...currencyPairsToGather, ...symbolProfilesToGather]; } private async getSymbolsMax(): Promise { diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 9a468f10e..e5eda2d7e 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -14,8 +14,13 @@ import { DERIVED_CURRENCIES, PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + DATE_FORMAT, + getCurrencyFromSymbol, + getStartOfUtcDate, + isDerivedCurrency +} from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -70,7 +75,7 @@ export class DataProviderService { return false; } - public async getAssetProfiles(items: UniqueAsset[]): Promise<{ + public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ [symbol: string]: Partial; }> { const response: { @@ -168,7 +173,7 @@ export class DataProviderService { } public async getHistorical( - aItems: UniqueAsset[], + aItems: AssetProfileIdentifier[], aGranularity: Granularity = 'month', from: Date, to: Date @@ -238,7 +243,7 @@ export class DataProviderService { from, to }: { - dataGatheringItems: UniqueAsset[]; + dataGatheringItems: AssetProfileIdentifier[]; from: Date; to: Date; }): Promise<{ @@ -345,7 +350,7 @@ export class DataProviderService { useCache = true, user }: { - items: UniqueAsset[]; + items: AssetProfileIdentifier[]; requestTimeout?: number; useCache?: boolean; user?: UserWithSettings; @@ -371,7 +376,7 @@ export class DataProviderService { } // Get items from cache - const itemsToFetch: UniqueAsset[] = []; + const itemsToFetch: AssetProfileIdentifier[] = []; for (const { dataSource, symbol } of items) { if (useCache) { @@ -423,13 +428,18 @@ export class DataProviderService { continue; } - const symbols = dataGatheringItems.map((dataGatheringItem) => { - return dataGatheringItem.symbol; - }); + const symbols = dataGatheringItems + .filter(({ symbol }) => { + return !isDerivedCurrency(getCurrencyFromSymbol(symbol)); + }) + .map(({ symbol }) => { + return symbol; + }); const maximumNumberOfSymbolsPerRequest = dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? Number.MAX_SAFE_INTEGER; + for ( let i = 0; i < symbols.length; @@ -623,7 +633,7 @@ export class DataProviderService { dataGatheringItems }: { currency: string; - dataGatheringItems: UniqueAsset[]; + dataGatheringItems: AssetProfileIdentifier[]; }) { return dataGatheringItems.some(({ dataSource, symbol }) => { return ( diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 29d199ed7..1f08034cd 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -361,13 +361,13 @@ export class ExchangeRateDataService { const symbol = `${currencyFrom}${currencyTo}`; const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: startDate, lt: endDate }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol } - ] + ], + dateQuery: { gte: startDate, lt: endDate } }); if (marketData?.length > 0) { @@ -392,13 +392,13 @@ export class ExchangeRateDataService { } } else { const marketData = await this.marketDataService.getRange({ - dateQuery: { gte: startDate, lt: endDate }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyFrom}` } - ] + ], + dateQuery: { gte: startDate, lt: endDate } }); for (const { date, marketPrice } of marketData) { @@ -415,16 +415,16 @@ export class ExchangeRateDataService { } } else { const marketData = await this.marketDataService.getRange({ - dateQuery: { - gte: startDate, - lt: endDate - }, - uniqueAssets: [ + assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyTo}` } - ] + ], + dateQuery: { + gte: startDate, + lt: endDate + } }); for (const { date, marketPrice } of marketData) { diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index b945d0945..fa7fc4d09 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -1,4 +1,7 @@ -import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + DataProviderInfo +} from '@ghostfolio/common/interfaces'; import { MarketState } from '@ghostfolio/common/types'; import { @@ -34,6 +37,6 @@ export interface IDataProviderResponse { marketState: MarketState; } -export interface IDataGatheringItem extends UniqueAsset { +export interface IDataGatheringItem extends AssetProfileIdentifier { date?: Date; } diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index faf429955..09f591b9e 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { resetHours } from '@ghostfolio/common/helper'; -import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; import { @@ -17,7 +17,7 @@ import { export class MarketDataService { public constructor(private readonly prismaService: PrismaService) {} - public async deleteMany({ dataSource, symbol }: UniqueAsset) { + public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.deleteMany({ where: { dataSource, @@ -40,7 +40,7 @@ export class MarketDataService { }); } - public async getMax({ dataSource, symbol }: UniqueAsset) { + public async getMax({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.marketData.findFirst({ select: { date: true, @@ -59,11 +59,11 @@ export class MarketDataService { } public async getRange({ - dateQuery, - uniqueAssets + assetProfileIdentifiers, + dateQuery }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; dateQuery: DateQuery; - uniqueAssets: UniqueAsset[]; }): Promise { return this.prismaService.marketData.findMany({ orderBy: [ @@ -76,13 +76,13 @@ export class MarketDataService { ], where: { dataSource: { - in: uniqueAssets.map(({ dataSource }) => { + in: assetProfileIdentifiers.map(({ dataSource }) => { return dataSource; }) }, date: dateQuery, symbol: { - in: uniqueAssets.map(({ symbol }) => { + in: assetProfileIdentifiers.map(({ symbol }) => { return symbol; }) } diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 1d7ea556b..50cb25000 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,10 +1,10 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { + AssetProfileIdentifier, EnhancedSymbolProfile, Holding, - ScraperConfiguration, - UniqueAsset + ScraperConfiguration } from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; @@ -23,7 +23,7 @@ export class SymbolProfileService { return this.prismaService.symbolProfile.create({ data: assetProfile }); } - public async delete({ dataSource, symbol }: UniqueAsset) { + public async delete({ dataSource, symbol }: AssetProfileIdentifier) { return this.prismaService.symbolProfile.delete({ where: { dataSource_symbol: { dataSource, symbol } } }); @@ -36,7 +36,7 @@ export class SymbolProfileService { } public async getSymbolProfiles( - aUniqueAssets: UniqueAsset[] + aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { return this.prismaService.symbolProfile .findMany({ @@ -54,7 +54,7 @@ export class SymbolProfileService { SymbolProfileOverrides: true }, where: { - OR: aUniqueAssets.map(({ dataSource, symbol }) => { + OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { return { dataSource, symbol @@ -91,6 +91,40 @@ export class SymbolProfileService { }); } + public async getSymbolProfilesByUserSubscription({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }) { + return this.prismaService.symbolProfile.findMany({ + include: { + Order: { + include: { + User: true + } + } + }, + orderBy: [{ symbol: 'asc' }], + where: { + Order: withUserSubscription + ? { + some: { + User: { + Subscription: { some: { expiresAt: { gt: new Date() } } } + } + } + } + : { + every: { + User: { + Subscription: { none: { expiresAt: { gt: new Date() } } } + } + } + } + } + }); + } + public updateSymbolProfile({ assetClass, assetSubClass, @@ -106,7 +140,7 @@ export class SymbolProfileService { symbolMapping, SymbolProfileOverrides, url - }: Prisma.SymbolProfileUpdateInput & UniqueAsset) { + }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) { return this.prismaService.symbolProfile.update({ data: { assetClass, diff --git a/apps/client/project.json b/apps/client/project.json index 153ae02d1..dd644f8c1 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -36,6 +36,10 @@ "ngswConfigPath": "apps/client/ngsw-config.json" }, "configurations": { + "development-ca": { + "baseHref": "/ca/", + "localize": ["ca"] + }, "development-de": { "baseHref": "/de/", "localize": ["de"] @@ -212,6 +216,7 @@ "includeContext": true, "outputPath": "src/locales", "targetFiles": [ + "messages.ca.xlf", "messages.de.xlf", "messages.es.xlf", "messages.fr.xlf", @@ -240,6 +245,10 @@ }, "i18n": { "locales": { + "ca": { + "baseHref": "/ca/", + "translation": "apps/client/src/locales/messages.ca.xlf" + }, "de": { "baseHref": "/de/", "translation": "apps/client/src/locales/messages.de.xlf" diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index e91818e09..806360c0f 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -145,6 +145,11 @@ />
  •  
  • +
  • Deutsch
  • diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 08cc915bd..4f1464408 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit { this.user?.permissions, permissions.reportDataGlitch ), + hasPermissionToUpdateOrder: + !this.hasImpersonationId && + hasPermission(this.user?.permissions, permissions.updateOrder) && + !user?.settings?.isRestrictedView, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 5494e6842..98a1d0480 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -6,8 +6,14 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper'; -import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + Filter, + InfoItem, + User +} from '@ghostfolio/common/interfaces'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { translate } from '@ghostfolio/ui/i18n'; import { SelectionModel } from '@angular/cdk/collections'; @@ -97,22 +103,11 @@ export class AdminMarketDataComponent new MatTableDataSource(); public defaultDateFormat: string; public deviceType: string; - public displayedColumns = [ - 'select', - 'nameWithSymbol', - 'dataSource', - 'assetClass', - 'assetSubClass', - 'date', - 'activitiesCount', - 'marketDataItemCount', - 'sectorsCount', - 'countriesCount', - 'comment', - 'actions' - ]; + public displayedColumns: string[] = []; public filters$ = new Subject(); public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; + public hasPermissionForSubscription: boolean; + public info: InfoItem; public isLoading = false; public isUUID = isUUID; public placeholder = ''; @@ -134,6 +129,33 @@ export class AdminMarketDataComponent private router: Router, private userService: UserService ) { + this.info = this.dataService.fetchInfo(); + + this.hasPermissionForSubscription = hasPermission( + this.info?.globalPermissions, + permissions.enableSubscription + ); + + this.displayedColumns = [ + 'select', + 'nameWithSymbol', + 'dataSource', + 'assetClass', + 'assetSubClass', + 'date', + 'activitiesCount', + 'marketDataItemCount', + 'sectorsCount', + 'countriesCount' + ]; + + if (this.hasPermissionForSubscription) { + this.displayedColumns.push('isUsedByUsersWithSubscription'); + } + + this.displayedColumns.push('comment'); + this.displayedColumns.push('actions'); + this.route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { @@ -203,7 +225,7 @@ export class AdminMarketDataComponent }); } - public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) { + public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); } @@ -244,21 +266,27 @@ export class AdminMarketDataComponent .subscribe(() => {}); } - public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherProfileDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) { + public onOpenAssetProfileDialog({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.router.navigate([], { queryParams: { dataSource, diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index 3dc3dd5a9..f3b2d8ddd 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -144,6 +144,15 @@ + + + + @if (element.isUsedByUsersWithSubscription) { + + } + + + diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts index 87562460a..224e3506b 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts @@ -1,5 +1,6 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -24,6 +25,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/ GfActivitiesFilterComponent, GfAssetProfileDialogModule, GfCreateAssetProfileDialogModule, + GfPremiumIndicatorComponent, GfSymbolModule, MatButtonModule, MatCheckboxModule, diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts index 8f3084cd8..799606293 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -2,8 +2,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { - AdminMarketDataItem, - UniqueAsset + AssetProfileIdentifier, + AdminMarketDataItem } from '@ghostfolio/common/interfaces'; import { Injectable } from '@angular/core'; @@ -13,7 +13,7 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs'; export class AdminMarketDataService { public constructor(private adminService: AdminService) {} - public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) { + public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { const confirmation = confirm( $localize`Do you really want to delete this asset profile?` ); @@ -29,15 +29,19 @@ export class AdminMarketDataService { } } - public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) { + public deleteAssetProfiles( + aAssetProfileIdentifiers: AssetProfileIdentifier[] + ) { const confirmation = confirm( $localize`Do you really want to delete these profiles?` ); if (confirmation) { - const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => { - return this.adminService.deleteProfileData({ dataSource, symbol }); - }); + const deleteRequests = aAssetProfileIdentifiers.map( + ({ dataSource, symbol }) => { + return this.adminService.deleteProfileData({ dataSource, symbol }); + } + ); forkJoin(deleteRequests) .pipe( diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 18b73a0cb..a24d6dc30 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -8,7 +8,7 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, - UniqueAsset + AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; @@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit { this.dialogRef.close(); } - public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { + public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.dialogRef.close(); } - public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherProfileDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.adminService .gatherProfileDataBySymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {}); } - public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { + public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { this.adminService .gatherSymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -242,7 +245,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } } - public onSetBenchmark({ dataSource, symbol }: UniqueAsset) { + public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .postBenchmark({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -342,7 +345,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } - public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) { + public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { this.dataService .deleteBenchmark({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index 5c052f21f..a1bce7616 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -12,11 +12,7 @@
    User Count
    - +
    @@ -24,7 +20,6 @@
    @if (transactionCount && userCount) { diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 6ac058f7e..5673cd0c0 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -4,6 +4,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DataProviderInfo, @@ -18,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfValueComponent } from '@ghostfolio/ui/value'; +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, ChangeDetectorRef, Component, + ElementRef, Inject, OnDestroy, - OnInit + OnInit, + ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; import { @@ -35,14 +44,15 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; import { Account, Tag } from '@prisma/client'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { Observable, of, Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; import { HoldingDetailDialogParams } from './interfaces/interfaces'; @@ -59,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; GfLineChartComponent, GfPortfolioProportionChartComponent, GfValueComponent, + MatAutocompleteModule, MatButtonModule, MatChipsModule, MatDialogModule, + MatFormFieldModule, MatTabsModule, NgxSkeletonLoaderModule ], @@ -72,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; templateUrl: 'holding-detail-dialog.html' }) export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { + @ViewChild('tagInput') tagInput: ElementRef; + + public activityForm: FormGroup; public accounts: Account[]; public activities: Activity[]; public assetClass: string; @@ -84,28 +99,35 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public dataProviderInfo: DataProviderInfo; public dataSource: MatTableDataSource; public dividendInBaseCurrency: number; + public dividendInBaseCurrencyPrecision = 2; public dividendYieldPercentWithCurrencyEffect: number; public feeInBaseCurrency: number; + public filteredTagsObservable: Observable = of([]); public firstBuyDate: string; public historicalDataItems: LineChartItem[]; public investment: number; + public investmentPrecision = 2; public marketPrice: number; public maxPrice: number; public minPrice: number; public netPerformance: number; + public netPerformancePrecision = 2; public netPerformancePercent: number; public netPerformancePercentWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number; + public netPerformanceWithCurrencyEffectPrecision = 2; public quantity: number; public quantityPrecision = 2; public reportDataGlitchMail: string; public sectors: { [name: string]: { name: string; value: number }; }; + public separatorKeysCodes: number[] = [COMMA, ENTER]; public sortColumn = 'date'; public sortDirection: SortDirection = 'desc'; public SymbolProfile: EnhancedSymbolProfile; public tags: Tag[]; + public tagsAvailable: Tag[]; public totalItems: number; public transactionCount: number; public user: User; @@ -118,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { private dataService: DataService, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, + private formBuilder: FormBuilder, private userService: UserService ) {} public ngOnInit() { + const { tags } = this.dataService.fetchInfo(); + + this.activityForm = this.formBuilder.group({ + tags: [] + }); + + this.tagsAvailable = tags.map(({ id, name }) => { + return { + id, + name: translate(name) + }; + }); + + this.activityForm + .get('tags') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((tags) => { + this.dataService + .putHoldingTags({ + tags, + dataSource: this.data.dataSource, + symbol: this.data.symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + }); + this.dataService .fetchHoldingDetail({ dataSource: this.data.dataSource, @@ -161,10 +211,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { this.dataProviderInfo = dataProviderInfo; this.dataSource = new MatTableDataSource(orders.reverse()); this.dividendInBaseCurrency = dividendInBaseCurrency; + + if ( + this.data.deviceType === 'mobile' && + this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD + ) { + this.dividendInBaseCurrencyPrecision = 0; + } + this.dividendYieldPercentWithCurrencyEffect = dividendYieldPercentWithCurrencyEffect; + this.feeInBaseCurrency = feeInBaseCurrency; this.firstBuyDate = firstBuyDate; + this.historicalDataItems = historicalData.map( ({ averagePrice, date, marketPrice }) => { this.benchmarkDataItems.push({ @@ -178,26 +238,82 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }; } ); + this.investment = investment; + + if ( + this.data.deviceType === 'mobile' && + this.investment >= NUMERICAL_PRECISION_THRESHOLD + ) { + this.investmentPrecision = 0; + } + this.marketPrice = marketPrice; this.maxPrice = maxPrice; this.minPrice = minPrice; this.netPerformance = netPerformance; + + if ( + this.data.deviceType === 'mobile' && + this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD + ) { + this.netPerformancePrecision = 0; + } + this.netPerformancePercent = netPerformancePercent; + this.netPerformancePercentWithCurrencyEffect = netPerformancePercentWithCurrencyEffect; + this.netPerformanceWithCurrencyEffect = netPerformanceWithCurrencyEffect; + + if ( + this.data.deviceType === 'mobile' && + this.netPerformanceWithCurrencyEffect >= + NUMERICAL_PRECISION_THRESHOLD + ) { + this.netPerformanceWithCurrencyEffectPrecision = 0; + } + this.quantity = quantity; + + if (Number.isInteger(this.quantity)) { + this.quantityPrecision = 0; + } else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { + if (this.quantity < 1) { + this.quantityPrecision = 7; + } else if (this.quantity < 1000) { + this.quantityPrecision = 5; + } else if (this.quantity >= 10000000) { + this.quantityPrecision = 0; + } + } + this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.sectors = {}; this.SymbolProfile = SymbolProfile; + this.tags = tags.map(({ id, name }) => { return { id, name: translate(name) }; }); + + this.activityForm.setValue({ tags: this.tags }, { emitEvent: false }); + + this.filteredTagsObservable = this.activityForm.controls[ + 'tags' + ].valueChanges.pipe( + startWith(this.activityForm.get('tags').value), + map((aTags: Tag[] | null) => { + return aTags + ? this.filterTags(aTags) + : this.tagsAvailable.slice(); + }) + ); + this.transactionCount = transactionCount; this.totalItems = transactionCount; this.value = value; @@ -282,18 +398,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { } ); - if (Number.isInteger(this.quantity)) { - this.quantityPrecision = 0; - } else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { - if (this.quantity < 1) { - this.quantityPrecision = 7; - } else if (this.quantity < 1000) { - this.quantityPrecision = 5; - } else if (this.quantity > 10000000) { - this.quantityPrecision = 0; - } - } - this.changeDetectorRef.markForCheck(); } ); @@ -309,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } + public onAddTag(event: MatAutocompleteSelectedEvent) { + this.activityForm.get('tags').setValue([ + ...(this.activityForm.get('tags').value ?? []), + this.tagsAvailable.find(({ id }) => { + return id === event.option.value; + }) + ]); + + this.tagInput.nativeElement.value = ''; + } + public onClose() { this.dialogRef.close(); } @@ -333,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } + public onRemoveTag(aTag: Tag) { + this.activityForm.get('tags').setValue( + this.activityForm.get('tags').value.filter(({ id }) => { + return id !== aTag.id; + }) + ); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private filterTags(aTags: Tag[]) { + const tagIds = aTags.map(({ id }) => { + return id; + }); + + return this.tagsAvailable.filter(({ id }) => { + return !tagIds.includes(id); + }); + } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 843901e5a..b7474a7a3 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -47,6 +47,7 @@ [colorizeSign]="true" [isCurrency]="true" [locale]="data.locale" + [precision]="netPerformanceWithCurrencyEffectPrecision" [unit]="data.baseCurrency" [value]="netPerformanceWithCurrencyEffect" >Change with currency effectChangeInvestmentDividend @@ -371,7 +375,49 @@ - @if (tags?.length > 0) { +
    +
    + + Tags + + @for (tag of activityForm.get('tags')?.value; track tag.id) { + + {{ tag.name }} + + + } + + + + @for (tag of filteredTagsObservable | async; track tag.id) { + + {{ tag.name }} + + } + + +
    +
    + + @if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
    Tags
    diff --git a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts index c6cfce1ee..8178838ab 100644 --- a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts @@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams { deviceType: string; hasImpersonationId: boolean; hasPermissionToReportDataGlitch: boolean; + hasPermissionToUpdateOrder: boolean; locale: string; symbol: string; } diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 3b99adb06..fc51796d6 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -2,14 +2,14 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + AssetProfileIdentifier, PortfolioPosition, - UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HoldingType, - HoldingViewMode, + HoldingsViewMode, ToggleOption } from '@ghostfolio/common/types'; @@ -18,7 +18,7 @@ import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { skip, takeUntil } from 'rxjs/operators'; @Component({ selector: 'gf-home-holdings', @@ -26,6 +26,8 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './home-holdings.html' }) export class HomeHoldingsComponent implements OnDestroy, OnInit { + public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE'; + public deviceType: string; public hasImpersonationId: boolean; public hasPermissionToAccessHoldingsChart: boolean; @@ -37,7 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { { label: $localize`Closed`, value: 'CLOSED' } ]; public user: User; - public viewModeFormControl = new FormControl('TABLE'); + public viewModeFormControl = new FormControl( + HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE + ); private unsubscribeSubject = new Subject(); @@ -81,6 +85,21 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); } }); + + this.viewModeFormControl.valueChanges + .pipe( + // Skip inizialization: "new FormControl" + skip(1), + takeUntil(this.unsubscribeSubject) + ) + .subscribe((holdingsViewMode) => { + this.dataService + .putUserSetting({ holdingsViewMode }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + }); + }); } public onChangeHoldingType(aHoldingType: HoldingType) { @@ -89,7 +108,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.initialize(); } - public onSymbolClicked({ dataSource, symbol }: UniqueAsset) { + public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) { if (dataSource && symbol) { this.router.navigate([], { queryParams: { dataSource, symbol, holdingDetailDialog: true } @@ -122,9 +141,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.hasPermissionToAccessHoldingsChart && this.holdingType === 'ACTIVE' ) { - this.viewModeFormControl.enable(); + this.viewModeFormControl.enable({ emitEvent: false }); + + this.viewModeFormControl.setValue( + this.deviceType === 'mobile' + ? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE + : this.user?.settings?.holdingsViewMode || + HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE, + { emitEvent: false } + ); } else if (this.holdingType === 'CLOSED') { - this.viewModeFormControl.setValue('TABLE'); + this.viewModeFormControl.setValue( + HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE, + { emitEvent: false } + ); } this.holdings = undefined; diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index a2ea30a69..b3ebe941c 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -41,7 +41,8 @@ [holdings]="holdings" (treemapChartClicked)="onSymbolClicked($event)" /> - } @else if (viewModeFormControl.value === 'TABLE') { + } +
    } - } +
    diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 75e177749..9addc24b7 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -3,10 +3,11 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import { + AssetProfileIdentifier, LineChartItem, PortfolioPerformance, - UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -25,7 +26,7 @@ import { takeUntil } from 'rxjs/operators'; export class HomeOverviewComponent implements OnDestroy, OnInit { public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public deviceType: string; - public errors: UniqueAsset[]; + public errors: AssetProfileIdentifier[]; public hasError: boolean; public hasImpersonationId: boolean; public hasPermissionToCreateOrder: boolean; @@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { public isAllTimeLow: boolean; public isLoadingPerformance = true; public performance: PortfolioPerformance; + public precision = 2; public showDetails = false; public unit: string; public user: User; @@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.showDetails = + !this.user.settings.isRestrictedView && + this.user.settings.viewMode !== 'ZEN'; + + this.unit = this.showDetails ? this.user.settings.baseCurrency : '%'; + this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntil(this.unsubscribeSubject)) @@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { .subscribe(() => { this.update(); }); - - this.showDetails = - !this.user.settings.isRestrictedView && - this.user.settings.viewMode !== 'ZEN'; - - this.unit = this.showDetails ? this.user.settings.baseCurrency : '%'; } public onChangeDateRange(dateRange: DateRange) { @@ -134,6 +136,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { } ); + if ( + this.deviceType === 'mobile' && + this.performance.currentValueInBaseCurrency >= + NUMERICAL_PRECISION_THRESHOLD + ) { + this.precision = 0; + } + this.isLoadingPerformance = false; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 671e73ef2..8cd317428 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -88,6 +88,7 @@ [isLoading]="isLoadingPerformance" [locale]="user?.settings?.locale" [performance]="performance" + [precision]="precision" [showDetails]="showDetails" [unit]="unit" /> diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 4d205b761..3083184bb 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -14,7 +14,6 @@ import { ElementRef, Input, OnChanges, - OnInit, ViewChild } from '@angular/core'; import { CountUp } from 'countup.js'; @@ -26,7 +25,7 @@ import { isNumber } from 'lodash'; templateUrl: './portfolio-performance.component.html', styleUrls: ['./portfolio-performance.component.scss'] }) -export class PortfolioPerformanceComponent implements OnChanges, OnInit { +export class PortfolioPerformanceComponent implements OnChanges { @Input() deviceType: string; @Input() errors: ResponseError['errors']; @Input() isAllTimeHigh: boolean; @@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit { @Input() isLoading: boolean; @Input() locale = getLocale(); @Input() performance: PortfolioPerformance; + @Input() precision: number; @Input() showDetails: boolean; @Input() unit: string; @@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit { public constructor() {} - public ngOnInit() {} - public ngOnChanges() { + this.precision = this.precision >= 0 ? this.precision : 2; + if (this.isLoading) { if (this.value?.nativeElement) { this.value.nativeElement.innerHTML = ''; @@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit { if (isNumber(this.performance?.currentValueInBaseCurrency)) { new CountUp('value', this.performance?.currentValueInBaseCurrency, { decimal: getNumberFormatDecimal(this.locale), - decimalPlaces: - this.deviceType === 'mobile' && - this.performance?.currentValueInBaseCurrency >= 100000 - ? 0 - : 2, + decimalPlaces: this.precision, duration: 1, separator: getNumberFormatGroup(this.locale) }).start(); diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index a809d5ed2..d4476776c 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { public isWebAuthnEnabled: boolean; public language = document.documentElement.lang; public locales = [ + 'ca', 'de', 'de-CH', 'en-GB', diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 66eb37f61..8b512ce3f 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -72,6 +72,14 @@ Deutsch English + @if (user?.settings?.isExperimentalFeatures) { + + } @if (user?.settings?.isExperimentalFeatures) { Chinese (Community) @if (user?.settings?.isExperimentalFeatures) { - Polski (Community) + } Português (CommunityMulti-Language

    Use Ghostfolio in multiple languages: English, - Dutch, French, German, Italian, - Portuguese, Spanish and Turkish are currently - supported. + + + Dutch, French, German, Italian, + + Portuguese, Spanish and Turkish are currently supported.

    diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 7555f3540..72de38c20 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -186,6 +186,14 @@ title="Sackgeld.com – Apps für ein höheres Sackgeld" > +
    + +