Compare commits

...

61 Commits

Author SHA1 Message Date
43f5bb7773 Release 2.102.0 (#3648) 2024-08-07 20:46:57 +02:00
e85cc0fcfc Feature/clone or edit activity from account detail dialog (#3647)
* Clone or edit activity from holding detail dialog

* Update changelog
2024-08-07 20:45:46 +02:00
dc1948016f Feature/clone or edit activity from holding detail dialog (#3644)
* Clone or edit activity from holding detail dialog

* Update changelog
2024-08-07 20:45:03 +02:00
4410040a14 Feature/update angular url in README.md (#3566)
Update Angular url
2024-08-06 16:53:31 +02:00
b2ed0b2c80 Feature/improve caching of benchmarks in markets overview (#3640)
* Improve caching

* Update changelog
2024-08-05 19:44:24 +02:00
42fe653e1e Bugfix/fix cache flush endpoint response (#3641)
* Fix cache flush endpoint response

* Update changelog
2024-08-05 19:43:25 +02:00
8a81fa814f Feature/improve language localization for pl (#3643)
* Update translations
2024-08-04 16:52:32 +02:00
98f3fa9d7c Feature/improve language localization for de 20240804 (#3639)
* Update translations

* Update changelog
2024-08-04 09:24:48 +02:00
202e27fe25 Feature/improve language localization for polish (#3637)
* Improve language localization for Polish

* Update changelog
2024-08-04 08:56:55 +02:00
757ff527d0 Feature/extend personal finance tools 20240803 (#3634)
* Add Capitalyse
2024-08-04 08:35:01 +02:00
41f5801b5e Feature/refactor unique asset type to asset profile identifier (#3636)
* Refactoring
2024-08-04 08:27:05 +02:00
4c7657a90e Feature/upgrade nx to version 19.5.6 (#3633)
* Upgrade Nx to version 19.5.6

* Update changelog
2024-08-04 08:22:32 +02:00
aef650753e Feature/clean up activities page (#3635)
* Clean up
2024-08-04 08:18:54 +02:00
420f331be9 Release 2.101.0 (#3632) 2024-08-03 16:58:08 +02:00
e0068c4d5d Feature/harden container security following OWASP best practices (#3614)
* Harden container security

* Update changelog
2024-08-03 16:55:18 +02:00
85661884a6 Release 2.100.0 (#3631) 2024-08-03 15:47:52 +02:00
8f6203d296 Feature/manage tags of holdings (#3630)
* Manage tags of holdings

* Update changelog
2024-08-03 15:46:01 +02:00
2fa723dc3c Bugfix/fix language selector of user account settings (#3613)
Fix value of Català
2024-08-03 15:42:21 +02:00
a500fb72c5 Feature/refactor Angular Material theme (#3629) 2024-08-02 20:57:03 +02:00
02db0db733 Feature/persist view mode of holdings tab on home page (#3624)
* Persist view mode of holdings in user settings

* Update changelog
2024-08-02 20:27:58 +02:00
c87b08ca8b Feature/improve language localization for es (#3625)
* Update translations

* Update changelog
2024-08-01 20:28:42 +02:00
fcc2ab1a48 Feature/change color assignment by annualized performance in treemap chart (#3617)
* Change color assignment to annualized performance

* Update changelog
2024-07-31 19:18:50 +02:00
7efda2f890 Feature/improve language localization for Catalan (#3598)
* Update translations

* Update changelog
2024-07-30 14:30:08 +02:00
3794a61d2d Release 2.99.0 (#3618) 2024-07-29 20:10:26 +02:00
c1d1ea9dde Feature/migrate from Yarn 1 (Classic) to npm (#3601)
* Migrate from yarn to npm
2024-07-29 20:08:43 +02:00
0d676a46c8 Release 2.98.0 (#3615) 2024-07-27 19:53:44 +02:00
97db144e01 Feature/skip derived currencies in get quotes of data provider service (#3610)
* Skip derived currencies

* Update changelog
2024-07-27 19:47:06 +02:00
cec55127c8 Bugix/fix dividend import from data provider for holdings without account (#3606)
* Fix dividend import for holdings without account

* Update changelog
2024-07-27 19:45:12 +02:00
f3f359bcfb Feature/Improve language localization for spanish (#3612)
* Update messages.es.xlf
2024-07-26 15:55:26 +02:00
601e6f4147 Feature/improve account selector of create or update activity dialog (#3607)
* Improve empty value of account selector

* Update changelog
2024-07-25 19:39:07 +02:00
e228b4925c Feature/update notes of personal finance tools (#3611)
* Update notes
2024-07-25 19:38:52 +02:00
62e3ffe413 Feature/upgrade prisma to version 5.17.0 (#3597)
* Upgrade prisma to version 5.17.0

* Update changelog
2024-07-24 19:28:05 +02:00
6af885fde0 Feature/improve language localization for Spanish (#3605)
* Improve language localization for Spanish

* Update changelog
2024-07-24 11:51:58 +02:00
dd15bba359 Bugfix/fix public page for non existent access (#3604)
* Handle non-existent access

* Update changelog
2024-07-23 21:00:20 +02:00
43fca7ff43 Feature/improve personal finance tools product page (#3599)
* Localize origin
* Localize regions
* Localize tags
2024-07-23 20:59:23 +02:00
faa6af5694 Feature/improve handling of numerical precision in value component (#3595)
* Improve handling of numerical precision in value component

* Update changelog
2024-07-22 19:35:25 +02:00
d2ea7a0bfb Feature/upgrade nx to version 19.5.1 (#3596)
* Upgrade angular and Nx

* Update changelog
2024-07-21 09:41:16 +02:00
3f6319e00b Feature/setup catala (#3593)
* Set up Català

* Update changelog
2024-07-20 17:13:44 +02:00
5601299648 Release 2.97.0 (#3592) 2024-07-20 11:24:47 +02:00
6060c7cfe0 Feature/upgrade prettier to version 3.3.3 (#3586)
* Upgrade prettier to version 3.3.3

* Update changelog
2024-07-20 11:18:00 +02:00
ba78c2783d Feature/improve numerical precision in holding detail dialog (#3584)
* Improve numerical precision in holding detail dialog

* Update changelog
2024-07-20 11:17:36 +02:00
48eee5f865 Feature/upgrade node.js from version 18 to 20 (#3553)
* Upgrade to Node.js 20

* Update changelog
2024-07-20 10:30:05 +02:00
f4a8acdb46 Feature/add selfh.st logo to landing page (#3582)
* Add selfh.st

* Update changelog
2024-07-20 10:13:28 +02:00
1d6ba22598 Feature/improve language localization for de (#3583)
* Update translations
2024-07-20 10:12:49 +02:00
e38be8d710 Feature/upgrade nx to version 19.4.3 (#3581)
* Upgrade Nx to version 19.4.3

* Update changelog
2024-07-19 11:56:18 +02:00
da5be3fb57 Feature/reuse open-color in portfolio proportion chart component (#3562)
* Reuse open-color
2024-07-18 10:14:12 +02:00
b5317a7f95 Feature/improve language localization for de 20240715 (#3574)
* Update translations

* Update changelog
2024-07-17 17:37:56 +02:00
43afb16808 Feature/introduce isUsedByUsersWithSubscription flag (#3573) 2024-07-16 20:51:49 +02:00
d5c56fb16c Feature/optimize 7d data gathering by prioritization (#3575)
* Optimize 7d data gathering by prioritization

* Update changelog
2024-07-16 20:45:34 +02:00
b94c1f280b Bugfix/fix spacing on pricing page (#3571)
* Fix spacing
2024-07-16 20:42:41 +02:00
acc59866a3 Bugfix/fix table sorting of holdings (#3572)
* Hide holdings table to fix sorting

* Update changelog
2024-07-15 15:14:34 +02:00
c9fc3e402d Release 2.96.0 (#3570) 2024-07-13 20:13:53 +02:00
6c1317f978 Bugfix/fix search for holding in assistant (#3569)
* Fix search for holding

* Update changelog
2024-07-13 20:11:40 +02:00
89be438e66 Bugfix/remove show condition of experimental features setting (#3568)
* Remove show condition of experimental feature setting

* Update changelog
2024-07-13 19:02:47 +02:00
9d6214e93a Bugfix/fix fees calculation in portfolio summary (#3567)
* Fix fees calculation

* Update changelog
2024-07-13 18:24:03 +02:00
0640b24290 Feature/improve site.webmanifest (#3564)
* Separate icon purposes

* Update changelog
2024-07-13 11:40:45 +02:00
6eb9d9d973 Feature/extend personal finance tools 20240713 (#3565) 2024-07-13 11:40:29 +02:00
9ecc3176a5 Feature/improve treemap chart for holdings (#3563)
* Various improvements

* Introduce permission: accessHoldingsChart
* Improve style of toggle
* Add border radius

* Update changelog
2024-07-13 10:45:10 +02:00
96434c5a54 Release 2.95.0 (#3561) 2024-07-12 21:04:38 +02:00
4063c62a17 Feature/setup treemap chart for holdings (#3560)
* Setup treemap chart

* Update changelog
2024-07-12 21:02:12 +02:00
890c5b986c Feature/improve formatting of variables in README.md (#3546) 2024-07-10 17:22:47 +02:00
141 changed files with 49170 additions and 24377 deletions

View File

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 18 - 20
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -24,16 +24,16 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
cache: 'yarn' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: yarn install --frozen-lockfile run: npm ci
- name: Check formatting - name: Check formatting
run: yarn format:check run: npm run format:check
- name: Execute tests - name: Execute tests
run: yarn test run: npm test
- name: Build application - name: Build application
run: yarn build:production run: npm run build:production

4
.gitignore vendored
View File

@ -5,8 +5,8 @@
/tmp /tmp
# dependencies # dependencies
/.yarn
/node_modules /node_modules
npm-debug.log
# IDEs and editors # IDEs and editors
/.idea /.idea
@ -34,10 +34,8 @@
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log
testem.log testem.log
/typings /typings
yarn-error.log
# System Files # System Files
.DS_Store .DS_Store

2
.nvmrc
View File

@ -1 +1 @@
v18 v20

View File

@ -1 +0,0 @@
network-timeout 600000

View File

@ -5,6 +5,113 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.102.0 - 2024-08-07
### Added
- Added support to clone an activity from the account detail dialog (experimental)
- Added support to edit an activity from the account detail dialog (experimental)
- Added support to clone an activity from the holding detail dialog (experimental)
- Added support to edit an activity from the holding detail dialog (experimental)
### Changed
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
### Fixed
- Fixed the cache flush endpoint response
## 2.101.0 - 2024-08-03
### Changed
- 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
- Improved the chart of the holdings tab on the home page (experimental)
- Separated the icon purposes in the `site.webmanifest`
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12
### Added
- Added a chart to the holdings tab of the home page (experimental)
## 2.94.0 - 2024-07-09 ## 2.94.0 - 2024-07-09
### Changed ### Changed
@ -4793,7 +4900,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the attribute `precision` in the value component - Added the attribute `precision` to the value component
### Fixed ### Fixed

View File

@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
#### Upgrade #### Upgrade
1. Run `yarn nx migrate latest` 1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install` 1. Make sure `package.json` changes make sense and then run `npm 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 --run-migrations`
### Prisma ### Prisma
#### Access database via GUI #### Access database via GUI
Run `yarn database:gui` Run `npm run database:gui`
https://www.prisma.io/studio https://www.prisma.io/studio
#### Synchronize schema with database for prototyping #### 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 https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration #### 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 https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -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 # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
COPY ./CHANGELOG.md CHANGELOG.md COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./package-lock.json package-lock.json
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \ RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \ g++ \
git \ git \
make \ make \
openssl \ openssl \
python3 \ python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN yarn install RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps
RUN yarn build:production RUN npm run build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api 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 # 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" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production
RUN apt update && apt install -y \ RUN apt-get update && apt-get install -y --no-install-suggests \
curl \ curl \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chown -R node:node /ghostfolio
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node
CMD [ "/ghostfolio/entrypoint.sh" ] CMD [ "/ghostfolio/entrypoint.sh" ]

View File

@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### Frontend ### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Self-hosting ## Self-hosting
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -149,16 +149,15 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 18+) - [Node.js](https://nodejs.org/en/download) (version 20+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - 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`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### 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 `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. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Open https://localhost:4200/en in your browser 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 #### 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 #### Serve
Run `yarn start:server` Run `npm run start:server`
### Start Client ### 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_ ### Start _Storybook_
Run `yarn start:storybook` Run `npm run start:storybook`
### Migrate Database ### Migrate Database
With the following command you can keep your database schema in sync: With the following command you can keep your database schema in sync:
```bash ```bash
yarn database:push npm run database:push
``` ```
## Testing ## Testing
Run `yarn test` Run `npm test`
## Public API ## Public API
@ -234,17 +233,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
``` ```
| Field | Type | Description | | Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- | | ------------ | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| comment | string (`optional`) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| fee | number | Fee of the activity | | `fee` | `number` | Fee of the activity |
| quantity | number | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response

View File

@ -174,8 +174,8 @@ export class AccountService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {

View File

@ -81,10 +81,11 @@ export class AdminController {
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -107,10 +108,11 @@ export class AdminController {
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

View File

@ -21,18 +21,19 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma, Prisma,
PrismaClient,
Property, Property,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
@ -58,7 +59,9 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
symbol symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> { }: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({ 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.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
} }
@ -212,8 +218,11 @@ export class AdminService {
} }
} }
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([ let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy, orderBy,
skip, skip,
take, take,
@ -229,6 +238,7 @@ export class AdminService {
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true, id: true,
isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -243,8 +253,9 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
let marketData: AdminMarketDataItem[] = assetProfiles.map( let marketData: AdminMarketDataItem[] = await Promise.all(
({ assetProfiles.map(
async ({
_count, _count,
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -253,12 +264,15 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
id, id,
isUsedByUsersWithSubscription,
name, name,
Order, Order,
sectors, sectors,
symbol symbol
}) => { }) => {
const countriesCount = countries ? Object.keys(countries).length : 0; const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -281,9 +295,11 @@ export class AdminService {
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.Order,
date: Order?.[0]?.date date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
}; };
} }
)
); );
if (presetId) { if (presetId) {
@ -304,12 +320,17 @@ export class AdminService {
count, count,
marketData marketData
}; };
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
@ -370,7 +391,7 @@ export class AdminService {
symbol, symbol,
symbolMapping, symbolMapping,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = { const symbolProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
@ -378,8 +399,8 @@ export class AdminService {
url: url as string url: url as string
}; };
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset = const updatedSymbolProfile: AssetProfileIdentifier &
{ Prisma.SymbolProfileUpdateInput = {
comment, comment,
countries, countries,
currency, currency,
@ -431,6 +452,52 @@ export class AdminService {
return response; 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<AdminMarketData> { private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,

View File

@ -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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -41,7 +41,9 @@ export class BenchmarkController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(
@Body() { dataSource, symbol }: AssetProfileIdentifier
) {
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,

View File

@ -17,11 +17,11 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
@ -29,15 +29,19 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addHours,
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
format, format,
isAfter,
isSameDay, isSameDay,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
@ -61,7 +65,10 @@ export class BenchmarkService {
return 0; return 0;
} }
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) { public async getBenchmarkTrends({
dataSource,
symbol
}: AssetProfileIdentifier) {
const historicalData = await this.marketDataService.marketDataItems({ const historicalData = await this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -89,99 +96,26 @@ export class BenchmarkService {
enableSharing = false, enableSharing = false,
useCache = true useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> { } = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) { if (useCache) {
try { try {
benchmarks = JSON.parse( const cachedBenchmarkValue = await this.redisCacheService.get(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) this.CACHE_KEY_BENCHMARKS
); );
if (benchmarks) { const { benchmarks, expiration }: BenchmarkValue =
return benchmarks; JSON.parse(cachedBenchmarkValue);
if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({
enableSharing
});
} }
return benchmarks;
} catch {} } catch {}
} }
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ return this.calculateAndCacheBenchmarks({ enableSharing });
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = useCache;
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('2 hours') / 1000
);
}
return benchmarks;
} }
public async getBenchmarkAssetProfiles({ public async getBenchmarkAssetProfiles({
@ -228,7 +162,7 @@ export class BenchmarkService {
endDate?: Date; endDate?: Date;
startDate: Date; startDate: Date;
userCurrency: string; userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1; const days = differenceInDays(endDate, startDate) + 1;
@ -348,7 +282,7 @@ export class BenchmarkService {
public async addBenchmark({ public async addBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -385,7 +319,7 @@ export class BenchmarkService {
public async deleteBenchmark({ public async deleteBenchmark({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -419,6 +353,95 @@ export class BenchmarkService {
}; };
} }
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
const expiration = addHours(new Date(), 2);
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{
benchmarks,
expiration: expiration.getTime()
}),
ms('12 hours') / 1000
);
}
return benchmarks;
}
private getMarketCondition( private getMarketCondition(
aPerformanceInPercent: number aPerformanceInPercent: number
): Benchmark['marketCondition'] { ): Benchmark['marketCondition'] {

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount,
@ -51,7 +51,7 @@ export class ImportService {
dataSource, dataSource,
symbol, symbol,
userCurrency userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getPosition(dataSource, undefined, symbol);
@ -72,8 +72,12 @@ export class ImportService {
}) })
]); ]);
const accounts = orders.map((order) => { const accounts = orders
return order.Account; .filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;

View File

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({ public async getLogoByDataSourceAndSymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset) { }: AssetProfileIdentifier) {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),

View File

@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface'; import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -140,6 +140,38 @@ export class OrderController {
return { activities, count }; return { activities, count };
} }
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<Activity> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
const activity = activities.find((activity) => {
return activity.id === id;
});
if (!activity) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return activity;
}
@HasPermission(permissions.createOrder) @HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

View File

@ -11,9 +11,9 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -46,6 +46,39 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService 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( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
@ -252,7 +285,7 @@ export class OrderService {
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) { public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -312,10 +345,14 @@ export class OrderService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -357,6 +394,30 @@ export class OrderService {
}; };
} }
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) { if (filtersByTag?.length > 0) {
where.tags = { where.tags = {
some: { some: {
@ -403,7 +464,7 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const uniqueAssets = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
return { return {
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
@ -418,8 +479,9 @@ export class OrderService {
} }
); );
const assetProfiles = const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); assetProfileIdentifiers
);
const activities = orders.map((order) => { const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {

View File

@ -1,5 +1,8 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; 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'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MWRPortfolioCalculator extends PortfolioCalculator {
@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
} }

View File

@ -19,12 +19,12 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
ResponseError, ResponseError,
SymbolMetrics, SymbolMetrics
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types'; import { DateRange, GroupBy } from '@ghostfolio/common/types';
@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
);
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul( ).mul(
@ -340,24 +346,25 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({ positions.push({
dividend: totalDividend, feeInBaseCurrency,
dividendInBaseCurrency: totalDividendInBaseCurrency,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
fee: item.fee, fee: item.fee,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null ? (grossPerformancePercentage ?? null)
: null, : null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors grossPerformancePercentageWithCurrencyEffect: !hasErrors
? grossPerformancePercentageWithCurrencyEffect ?? null ? (grossPerformancePercentageWithCurrencyEffect ?? null)
: null, : null,
grossPerformanceWithCurrencyEffect: !hasErrors grossPerformanceWithCurrencyEffect: !hasErrors
? grossPerformanceWithCurrencyEffect ?? null ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
@ -365,15 +372,15 @@ export abstract class PortfolioCalculator {
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null, marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null, netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null ? (netPerformancePercentage ?? null)
: null, : null,
netPerformancePercentageWithCurrencyEffect: !hasErrors netPerformancePercentageWithCurrencyEffect: !hasErrors
? netPerformancePercentageWithCurrencyEffect ?? null ? (netPerformancePercentageWithCurrencyEffect ?? null)
: null, : null,
netPerformanceWithCurrencyEffect: !hasErrors netPerformanceWithCurrencyEffect: !hasErrors
? netPerformanceWithCurrencyEffect ?? null ? (netPerformanceWithCurrencyEffect ?? null)
: null, : null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
@ -898,7 +905,7 @@ export abstract class PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics; } & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() { public getTransactionPoints() {
return this.transactionPoints; return this.transactionPoints;

View File

@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentage: new Big('-0.04408677396780965649'),

View File

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'), grossPerformancePercentage: new Big('42.41978276196153750666'),

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('49'), fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01', firstBuyDate: '2021-09-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,

View File

@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03', firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1'), totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'), totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056'), totalInvestmentWithCurrencyEffect: new Big('82.329056'),

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01', firstBuyDate: '2022-01-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,

View File

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),

View File

@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -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 { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT } from '@ghostfolio/common/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 { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -34,9 +37,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.fee) { if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee currentPosition.feeInBaseCurrency
); );
} }
@ -151,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
}; };
start: Date; start: Date;
step?: number; step?: number;
} & UniqueAsset): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {}; const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};

View File

@ -1,7 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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'; import { DataSource, MarketData } from '@prisma/client';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
}); });
}, },
getRange: ({ getRange: ({
assetProfileIdentifiers,
dateRangeEnd, dateRangeEnd,
dateRangeStart, dateRangeStart
uniqueAssets
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date; dateRangeEnd: Date;
dateRangeStart: Date; dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => { }) => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: uniqueAssets[0].symbol symbol: assetProfileIdentifiers[0].symbol
} }
]); ]);
} }

View File

@ -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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
DataProviderInfo, DataProviderInfo,
ResponseError, ResponseError
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -80,17 +80,16 @@ export class CurrentRateService {
); );
} }
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map( const assetProfileIdentifiers: AssetProfileIdentifier[] =
({ dataSource, symbol }) => { dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
} });
);
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery, assetProfileIdentifiers,
uniqueAssets dateQuery
}) })
.then((data) => { .then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {

View File

@ -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; date: Date;
marketPrice: number; marketPrice: number;
} }

View File

@ -1,6 +1,7 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
@ -29,7 +30,8 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
isRestrictedView isRestrictedView,
permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { import type {
DateRange, DateRange,
@ -38,12 +40,14 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
Headers, Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
Put,
Query, Query,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -51,12 +55,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -496,9 +501,6 @@ export class PortfolioController {
@Param('accessId') accessId @Param('accessId') accessId
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) { if (!access) {
throw new HttpException( throw new HttpException(
@ -508,6 +510,11 @@ export class PortfolioController {
} }
let hasDetails = true; let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
@ -564,25 +571,25 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition( public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol @Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> { ): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition( const holding = await this.portfolioService.getPosition(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol
); );
if (position) { if (!holding) {
return position;
}
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
); );
} }
return holding;
}
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@ -603,4 +610,36 @@ export class PortfolioController {
return report; 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<void> {
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
});
}
} }

View File

@ -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);
});
});
});

View File

@ -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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
@ -58,7 +59,8 @@ import {
DataSource, DataSource,
Order, Order,
Platform, Platform,
Prisma Prisma,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
@ -70,7 +72,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash'; import { isEmpty, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { 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({ public async getDividends({
activities, activities,
groupBy groupBy
@ -713,7 +697,7 @@ export class PortfolioService {
return Account; return Account;
}); });
const dividendYieldPercent = this.getAnnualizedPerformancePercent({ const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0) netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0) ? new Big(0)
@ -721,7 +705,7 @@ export class PortfolioService {
}); });
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0 0
@ -1321,6 +1305,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({ private async getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
@ -1724,13 +1726,13 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big(netPerformancePercentage) netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber(); })?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect = const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercentage: new Big( netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect netPerformancePercentageWithCurrencyEffect

View File

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

View File

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; 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 { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -28,7 +28,7 @@ export class RedisCacheService {
return `portfolio-snapshot-${userId}`; return `portfolio-snapshot-${userId}`;
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }

View File

@ -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; currency: string;
historicalData: HistoricalDataItem[]; historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;

View File

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData; const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource: dataGatheringItem.dataSource, dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
} }
] ],
dateQuery: { gte: subDays(new Date(), days) }
}); });
historicalData = marketData.map(({ date, marketPrice: value }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {

View File

@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import type { import type {
ColorScheme, ColorScheme,
DateRange, DateRange,
HoldingsViewMode,
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;

View File

@ -190,7 +190,7 @@ export class UserService {
(user.Settings.settings as UserSettings).dateRange = (user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN' (user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max' ? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max'; : ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode // Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) { if (!(user.Settings.settings as UserSettings).viewMode) {
@ -237,11 +237,15 @@ export class UserService {
currentPermissions = without( currentPermissions = without(
currentPermissions, currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess permissions.createAccess
); );
// Reset benchmark // Reset benchmark
user.Settings.settings.benchmark = undefined; user.Settings.settings.benchmark = undefined;
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);

View File

@ -4,6 +4,12 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!--
<url>
<loc>https://ghostfol.io/ca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url> <url>
<loc>https://ghostfol.io/de</loc> <loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -45,10 +45,11 @@ export class CronService {
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

View File

@ -7,7 +7,7 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; 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 { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
) {} ) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS }) @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) { public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try { try {
Logger.log( Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`, `Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,

View File

@ -10,6 +10,7 @@ import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -19,7 +20,10 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
BenchmarkProperty
} from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -62,9 +66,22 @@ export class DataGatheringService {
} }
public async gather7Days() { public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols({ 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 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 }); await this.marketDataService.deleteMany({ dataSource, symbol });
const dataGatheringItems = (await this.getSymbolsMax()).filter( const dataGatheringItems = (await this.getSymbolsMax()).filter(
@ -132,23 +149,29 @@ export class DataGatheringService {
} }
} }
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) { public async gatherAssetProfiles(
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => { aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) {
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL'; return dataGatheringItem.dataSource !== 'MANUAL';
}); }
);
if (!uniqueAssets) { if (!assetProfileIdentifiers) {
uniqueAssets = await this.getUniqueAssets(); assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
} }
if (uniqueAssets.length <= 0) { if (assetProfileIdentifiers.length <= 0) {
return; return;
} }
const assetProfiles = const assetProfiles = await this.dataProviderService.getAssetProfiles(
await this.dataProviderService.getAssetProfiles(uniqueAssets); assetProfileIdentifiers
const symbolProfiles = );
await this.symbolProfileService.getSymbolProfiles(uniqueAssets); const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -234,7 +257,7 @@ export class DataGatheringService {
'DataGatheringService' 'DataGatheringService'
); );
if (uniqueAssets.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;
} }
} }
@ -270,7 +293,9 @@ export class DataGatheringService {
); );
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> { public async getAllAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }] orderBy: [{ symbol: 'asc' }]
}); });
@ -290,73 +315,83 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) { private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
return min([aStartDate, subYears(new Date(), 10)]); AssetProfileIdentifier[]
} > {
return (
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
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 = (
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate }, date: { gt: subDays(resetHours(new Date()), 7) },
state: 'CLOSE' state: 'CLOSE'
} }
}) })
) )
.filter((group) => { .filter(({ _count }) => {
return group._count >= 6; return _count >= 6;
}) })
.map((group) => { .map(({ dataSource, symbol }) => {
return group.symbol; return { dataSource, symbol };
});
}
private async getCurrencies7D(): Promise<IDataGatheringItem[]> {
const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData();
return this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ dataSource, symbol }) => {
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: subDays(resetHours(new Date()), 7)
};
});
}
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getSymbols7D({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}): Promise<IDataGatheringItem[]> {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
withUserSubscription
}); });
const symbolProfilesToGather = symbolProfiles const assetProfileIdentifiersWithCompleteMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData();
return symbolProfiles
.filter(({ dataSource, scraperConfiguration, symbol }) => { .filter(({ dataSource, scraperConfiguration, symbol }) => {
const manualDataSourceWithScraperConfiguration = const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return ( return (
!symbolsWithCompleteMarketData.includes(symbol) && !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
}) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
return { return {
...symbolProfile, ...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<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {

View File

@ -14,8 +14,13 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import {
import { UniqueAsset } from '@ghostfolio/common/interfaces'; DATE_FORMAT,
getCurrencyFromSymbol,
getStartOfUtcDate,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -70,7 +75,7 @@ export class DataProviderService {
return false; return false;
} }
public async getAssetProfiles(items: UniqueAsset[]): Promise<{ public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
}> { }> {
const response: { const response: {
@ -168,7 +173,7 @@ export class DataProviderService {
} }
public async getHistorical( public async getHistorical(
aItems: UniqueAsset[], aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -238,7 +243,7 @@ export class DataProviderService {
from, from,
to to
}: { }: {
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
@ -345,7 +350,7 @@ export class DataProviderService {
useCache = true, useCache = true,
user user
}: { }: {
items: UniqueAsset[]; items: AssetProfileIdentifier[];
requestTimeout?: number; requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
@ -371,7 +376,7 @@ export class DataProviderService {
} }
// Get items from cache // Get items from cache
const itemsToFetch: UniqueAsset[] = []; const itemsToFetch: AssetProfileIdentifier[] = [];
for (const { dataSource, symbol } of items) { for (const { dataSource, symbol } of items) {
if (useCache) { if (useCache) {
@ -423,13 +428,18 @@ export class DataProviderService {
continue; continue;
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = dataGatheringItems
return dataGatheringItem.symbol; .filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
.map(({ symbol }) => {
return symbol;
}); });
const maximumNumberOfSymbolsPerRequest = const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
for ( for (
let i = 0; let i = 0;
i < symbols.length; i < symbols.length;
@ -623,7 +633,7 @@ export class DataProviderService {
dataGatheringItems dataGatheringItems
}: { }: {
currency: string; currency: string;
dataGatheringItems: UniqueAsset[]; dataGatheringItems: AssetProfileIdentifier[];
}) { }) {
return dataGatheringItems.some(({ dataSource, symbol }) => { return dataGatheringItems.some(({ dataSource, symbol }) => {
return ( return (

View File

@ -361,13 +361,13 @@ export class ExchangeRateDataService {
const symbol = `${currencyFrom}${currencyTo}`; const symbol = `${currencyFrom}${currencyTo}`;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol symbol
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
if (marketData?.length > 0) { if (marketData?.length > 0) {
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: startDate, lt: endDate }, assetProfileIdentifiers: [
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}` symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
} }
] ],
dateQuery: { gte: startDate, lt: endDate }
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
} }
} else { } else {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { assetProfileIdentifiers: [
gte: startDate,
lt: endDate
},
uniqueAssets: [
{ {
dataSource, dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}` symbol: `${DEFAULT_CURRENCY}${currencyTo}`
} }
] ],
dateQuery: {
gte: startDate,
lt: endDate
}
}); });
for (const { date, marketPrice } of marketData) { for (const { date, marketPrice } of marketData) {

View File

@ -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 { MarketState } from '@ghostfolio/common/types';
import { import {
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
marketState: MarketState; marketState: MarketState;
} }
export interface IDataGatheringItem extends UniqueAsset { export interface IDataGatheringItem extends AssetProfileIdentifier {
date?: Date; date?: Date;
} }

View File

@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -17,7 +17,7 @@ import {
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({ dataSource, symbol }: UniqueAsset) { public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
dataSource, 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({ return this.prismaService.marketData.findFirst({
select: { select: {
date: true, date: true,
@ -59,11 +59,11 @@ export class MarketDataService {
} }
public async getRange({ public async getRange({
dateQuery, assetProfileIdentifiers,
uniqueAssets dateQuery
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateQuery: DateQuery; dateQuery: DateQuery;
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
@ -76,13 +76,13 @@ export class MarketDataService {
], ],
where: { where: {
dataSource: { dataSource: {
in: uniqueAssets.map(({ dataSource }) => { in: assetProfileIdentifiers.map(({ dataSource }) => {
return dataSource; return dataSource;
}) })
}, },
date: dateQuery, date: dateQuery,
symbol: { symbol: {
in: uniqueAssets.map(({ symbol }) => { in: assetProfileIdentifiers.map(({ symbol }) => {
return symbol; return symbol;
}) })
} }

View File

@ -1,10 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Holding, Holding,
ScraperConfiguration, ScraperConfiguration
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -23,7 +23,7 @@ export class SymbolProfileService {
return this.prismaService.symbolProfile.create({ data: assetProfile }); return this.prismaService.symbolProfile.create({ data: assetProfile });
} }
public async delete({ dataSource, symbol }: UniqueAsset) { public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
@ -36,7 +36,7 @@ export class SymbolProfileService {
} }
public async getSymbolProfiles( public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
@ -54,7 +54,7 @@ export class SymbolProfileService {
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
OR: aUniqueAssets.map(({ dataSource, symbol }) => { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol 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({ public updateSymbolProfile({
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -106,7 +140,7 @@ export class SymbolProfileService {
symbolMapping, symbolMapping,
SymbolProfileOverrides, SymbolProfileOverrides,
url url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
assetClass, assetClass,

View File

@ -36,6 +36,10 @@
"ngswConfigPath": "apps/client/ngsw-config.json" "ngswConfigPath": "apps/client/ngsw-config.json"
}, },
"configurations": { "configurations": {
"development-ca": {
"baseHref": "/ca/",
"localize": ["ca"]
},
"development-de": { "development-de": {
"baseHref": "/de/", "baseHref": "/de/",
"localize": ["de"] "localize": ["de"]
@ -212,6 +216,7 @@
"includeContext": true, "includeContext": true,
"outputPath": "src/locales", "outputPath": "src/locales",
"targetFiles": [ "targetFiles": [
"messages.ca.xlf",
"messages.de.xlf", "messages.de.xlf",
"messages.es.xlf", "messages.es.xlf",
"messages.fr.xlf", "messages.fr.xlf",
@ -240,6 +245,10 @@
}, },
"i18n": { "i18n": {
"locales": { "locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": { "de": {
"baseHref": "/de/", "baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf" "translation": "apps/client/src/locales/messages.de.xlf"

View File

@ -145,6 +145,11 @@
/></a> /></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
<!--
<li>
<a href="../ca" title="Ghostfolio en català">Català</a>
</li>
-->
<li> <li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a> <a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li> </li>

View File

@ -255,10 +255,18 @@ export class AppComponent implements OnDestroy, OnInit {
colorScheme: this.user?.settings?.colorScheme, colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView,
hasPermissionToReportDataGlitch: hasPermission( hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

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

View File

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

View File

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

View File

@ -6,8 +6,14 @@ import {
ghostfolioScraperApiSymbolPrefix ghostfolioScraperApiSymbolPrefix
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; 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 { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
@ -97,22 +103,11 @@ export class AdminMarketDataComponent
new MatTableDataSource(); new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public displayedColumns = [ public displayedColumns: string[] = [];
'select',
'nameWithSymbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activitiesCount',
'marketDataItemCount',
'sectorsCount',
'countriesCount',
'comment',
'actions'
];
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public isLoading = false; public isLoading = false;
public isUUID = isUUID; public isUUID = isUUID;
public placeholder = ''; public placeholder = '';
@ -134,6 +129,33 @@ export class AdminMarketDataComponent
private router: Router, private router: Router,
private userService: UserService 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 this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
@ -203,7 +225,7 @@ export class AdminMarketDataComponent
}); });
} }
public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) { public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
} }
@ -244,21 +266,27 @@ export class AdminMarketDataComponent
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) { public onOpenAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { queryParams: {
dataSource, dataSource,

View File

@ -144,6 +144,15 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="isUsedByUsersWithSubscription">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.isUsedByUsersWithSubscription) {
<gf-premium-indicator [enableLink]="false" />
}
</td>
</ng-container>
<ng-container matColumnDef="comment"> <ng-container matColumnDef="comment">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>

View File

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

View File

@ -2,8 +2,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataItem, AssetProfileIdentifier,
UniqueAsset AdminMarketDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
@ -13,7 +13,7 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
export class AdminMarketDataService { export class AdminMarketDataService {
public constructor(private adminService: AdminService) {} public constructor(private adminService: AdminService) {}
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const confirmation = confirm( const confirmation = confirm(
$localize`Do you really want to delete this asset profile?` $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( const confirmation = confirm(
$localize`Do you really want to delete these profiles?` $localize`Do you really want to delete these profiles?`
); );
if (confirmation) { if (confirmation) {
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => { const deleteRequests = aAssetProfileIdentifiers.map(
({ dataSource, symbol }) => {
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
}); }
);
forkJoin(deleteRequests) forkJoin(deleteRequests)
.pipe( .pipe(

View File

@ -8,7 +8,7 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
UniqueAsset AssetProfileIdentifier
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close(); this.dialogRef.close();
} }
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .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 this.dataService
.postBenchmark({ dataSource, symbol }) .postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .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 this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -12,11 +12,7 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Count</div> <div class="w-50" i18n>User Count</div>
<div class="w-50"> <div class="w-50">
<gf-value <gf-value [locale]="user?.settings?.locale" [value]="userCount" />
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
/>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
@ -24,7 +20,6 @@
<div class="w-50"> <div class="w-50">
<gf-value <gf-value
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount" [value]="transactionCount"
/> />
@if (transactionCount && userCount) { @if (transactionCount && userCount) {

View File

@ -4,6 +4,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.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 { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -18,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { import {
@ -35,14 +44,16 @@ import {
MatDialogModule, MatDialogModule,
MatDialogRef MatDialogRef
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Observable, of, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { map, startWith, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -59,9 +70,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfValueComponent, GfValueComponent,
MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule,
MatTabsModule, MatTabsModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
@ -72,6 +85,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup;
public accounts: Account[]; public accounts: Account[];
public activities: Activity[]; public activities: Activity[];
public assetClass: string; public assetClass: string;
@ -84,28 +100,35 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dataProviderInfo: DataProviderInfo; public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public firstBuyDate: string; public firstBuyDate: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public investmentPrecision = 2;
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number; public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public separatorKeysCodes: number[] = [COMMA, ENTER];
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public tagsAvailable: Tag[];
public totalItems: number; public totalItems: number;
public transactionCount: number; public transactionCount: number;
public user: User; public user: User;
@ -118,10 +141,39 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private router: Router,
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.activityForm = this.formBuilder.group({
tags: <string[]>[]
});
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 this.dataService
.fetchHoldingDetail({ .fetchHoldingDetail({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -161,10 +213,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse()); this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
) {
this.dividendInBaseCurrencyPrecision = 0;
}
this.dividendYieldPercentWithCurrencyEffect = this.dividendYieldPercentWithCurrencyEffect =
dividendYieldPercentWithCurrencyEffect; dividendYieldPercentWithCurrencyEffect;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -178,26 +240,82 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}; };
} }
); );
this.investment = investment; this.investment = investment;
if (
this.data.deviceType === 'mobile' &&
this.investment >= NUMERICAL_PRECISION_THRESHOLD
) {
this.investmentPrecision = 0;
}
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect = this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect; netPerformanceWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformanceWithCurrencyEffectPrecision = 0;
}
this.quantity = quantity; 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.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.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags.map(({ id, name }) => { this.tags = tags.map(({ id, name }) => {
return { return {
id, id,
name: translate(name) 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.transactionCount = transactionCount;
this.totalItems = transactionCount; this.totalItems = transactionCount;
this.value = value; this.value = value;
@ -282,18 +400,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(); this.changeDetectorRef.markForCheck();
} }
); );
@ -309,6 +415,25 @@ 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 onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, createDialog: true }
});
this.dialogRef.close();
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -333,8 +458,34 @@ 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 onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
this.dialogRef.close();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}
} }

View File

@ -47,6 +47,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="netPerformanceWithCurrencyEffectPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect" [value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value >Change with currency effect</gf-value
@ -58,6 +59,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="netPerformancePrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformance"
>Change</gf-value >Change</gf-value
@ -160,6 +162,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="investmentPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="investment" [value]="investment"
>Investment</gf-value >Investment</gf-value
@ -172,6 +175,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="dividendInBaseCurrency" [value]="dividendInBaseCurrency"
>Dividend</gf-value >Dividend</gf-value
@ -321,7 +325,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0" animationDuration="0"
class="mb-3" class="mb-5"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }" [ngClass]="{ 'd-none': !activities?.length }"
> >
@ -342,12 +346,19 @@
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
[showActions]="false" [showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateOrder &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
[showNameColumn]="false" [showNameColumn]="false"
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[sortDisabled]="true" [sortDisabled]="true"
[totalItems]="totalItems" [totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
/> />
</mat-tab> </mat-tab>
@ -371,7 +382,49 @@
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
@if (tags?.length > 0) { <div
class="row"
[ngClass]="{
'd-none': !data.hasPermissionToUpdateOrder
}"
>
<div class="col">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
</div>
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>

View File

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

View File

@ -1,14 +1,24 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types'; import {
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { skip, takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
@ -16,8 +26,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-holdings.html' templateUrl: './home-holdings.html'
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[]; public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE'; public holdingType: HoldingType = 'ACTIVE';
@ -26,6 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
public user: User; public user: User;
public viewModeFormControl = new FormControl<HoldingsViewMode>(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -34,6 +50,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private userService: UserService private userService: UserService
) {} ) {}
@ -53,39 +70,51 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions,
permissions.accessHoldingsChart
);
this.hasPermissionToCreateOrder = hasPermission( this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createOrder
); );
this.holdings = undefined; this.initialize();
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck(); 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) { public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType; this.holdingType = aHoldingType;
this.holdings = undefined; this.initialize();
}
this.fetchHoldings() public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) {
.pipe(takeUntil(this.unsubscribeSubject)) if (dataSource && symbol) {
.subscribe(({ holdings }) => { this.router.navigate([], {
this.holdings = holdings; queryParams: { dataSource, symbol, holdingDetailDialog: true }
this.changeDetectorRef.markForCheck();
}); });
} }
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
@ -104,4 +133,38 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}); });
} }
private initialize() {
this.viewModeFormControl.disable();
if (
this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE'
) {
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(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
}
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
} }

View File

@ -6,7 +6,25 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex justify-content-end"> <div class="d-flex">
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex">
<div class="d-none d-lg-block">
<mat-button-toggle-group
[formControl]="viewModeFormControl"
[hideSingleSelectionIndicator]="true"
>
<mat-button-toggle i18n-title title="Table" value="TABLE">
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" value="CHART">
<ion-icon name="grid-outline" />
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
}
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="holdingType" [defaultValue]="holdingType"
@ -15,6 +33,16 @@
(change)="onChangeHoldingType($event.value)" (change)="onChangeHoldingType($event.value)"
/> />
</div> </div>
</div>
@if (viewModeFormControl.value === 'CHART') {
<gf-treemap-chart
class="mt-3"
cursor="pointer"
[holdings]="holdings"
(treemapChartClicked)="onSymbolClicked($event)"
/>
}
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
@ -36,3 +64,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@ -1,9 +1,12 @@
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent], declarations: [HomeHoldingsComponent],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfToggleModule, GfToggleModule,
GfTreemapChartComponent,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,3 +1,9 @@
:host { :host {
display: block; display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-standard-button-toggle-height: 1.5rem;
}
}
} }

View File

@ -3,10 +3,11 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier,
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -25,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public errors: UniqueAsset[]; public errors: AssetProfileIdentifier[];
public hasError: boolean; public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
public isLoadingPerformance = true; public isLoadingPerformance = true;
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
public precision = 2;
public showDetails = false; public showDetails = false;
public unit: string; public unit: string;
public user: User; public user: User;
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; 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 this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.subscribe(() => { .subscribe(() => {
this.update(); 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) { 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.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -88,6 +88,7 @@
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performance]="performance" [performance]="performance"
[precision]="precision"
[showDetails]="showDetails" [showDetails]="showDetails"
[unit]="unit" [unit]="unit"
/> />

View File

@ -14,7 +14,6 @@ import {
ElementRef, ElementRef,
Input, Input,
OnChanges, OnChanges,
OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
@ -26,7 +25,7 @@ import { isNumber } from 'lodash';
templateUrl: './portfolio-performance.component.html', templateUrl: './portfolio-performance.component.html',
styleUrls: ['./portfolio-performance.component.scss'] styleUrls: ['./portfolio-performance.component.scss']
}) })
export class PortfolioPerformanceComponent implements OnChanges, OnInit { export class PortfolioPerformanceComponent implements OnChanges {
@Input() deviceType: string; @Input() deviceType: string;
@Input() errors: ResponseError['errors']; @Input() errors: ResponseError['errors'];
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() performance: PortfolioPerformance; @Input() performance: PortfolioPerformance;
@Input() precision: number;
@Input() showDetails: boolean; @Input() showDetails: boolean;
@Input() unit: string; @Input() unit: string;
@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
public constructor() {} public constructor() {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.precision = this.precision >= 0 ? this.precision : 2;
if (this.isLoading) { if (this.isLoading) {
if (this.value?.nativeElement) { if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = ''; this.value.nativeElement.innerHTML = '';
@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
if (isNumber(this.performance?.currentValueInBaseCurrency)) { if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValueInBaseCurrency, { new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces: this.precision,
this.deviceType === 'mobile' &&
this.performance?.currentValueInBaseCurrency >= 100000
? 0
: 2,
duration: 1, duration: 1,
separator: getNumberFormatGroup(this.locale) separator: getNumberFormatGroup(this.locale)
}).start(); }).start();

View File

@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
public isWebAuthnEnabled: boolean; public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang; public language = document.documentElement.lang;
public locales = [ public locales = [
'ca',
'de', 'de',
'de-CH', 'de-CH',
'en-GB', 'en-GB',

View File

@ -72,6 +72,14 @@
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="ca"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<mat-option value="zh" <mat-option value="zh"
>Chinese (<ng-container i18n>Community</ng-container >Chinese (<ng-container i18n>Community</ng-container
@ -95,10 +103,12 @@
>)</mat-option >)</mat-option
> >
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="pl" <mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container >Polski (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
-->
} }
<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
@ -196,7 +206,6 @@
/> />
</div> </div>
</div> </div>
@if (hasPermissionToUpdateUserSettings) {
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Experimental Features</div> <div i18n>Experimental Features</div>
@ -214,7 +223,6 @@
/> />
</div> </div>
</div> </div>
}
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>

View File

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

View File

@ -242,9 +242,11 @@
<h4 i18n>Multi-Language</h4> <h4 i18n>Multi-Language</h4>
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Use Ghostfolio in multiple languages: English,
<!-- Chinese, -->Dutch, French, German, Italian, <!--Català, -->
<!-- Polish, -->Portuguese, Spanish and Turkish are currently <!-- Chinese, -->
supported. Dutch, French, German, Italian,
<!-- Polish, -->
Portuguese, Spanish and Turkish are currently supported.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>

View File

@ -186,6 +186,14 @@
title="Sackgeld.com Apps für ein höheres Sackgeld" title="Sackgeld.com Apps für ein höheres Sackgeld"
></a> ></a>
</div> </div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-selfh-st mask"
href="https://selfh.st"
target="_blank"
title="selfh.st — Self-hosted content and software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1"> <div class="col-md-3 d-flex justify-content-center my-1">
<a <a
class="d-block logo logo-sourceforge mask" class="d-block logo logo-sourceforge mask"

View File

@ -80,6 +80,11 @@
mask-image: url('/assets/images/logo-sackgeld.png'); mask-image: url('/assets/images/logo-sackgeld.png');
} }
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge { &.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg'); mask-image: url('/assets/images/logo-sourceforge.svg');
} }
@ -131,6 +136,7 @@
&.logo-privacy-tools, &.logo-privacy-tools,
&.logo-reddit, &.logo-reddit,
&.logo-sackgeld, &.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge, &.logo-sourceforge,
&.logo-umbrel, &.logo-umbrel,
&.logo-unraid { &.logo-unraid {

View File

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

View File

@ -51,9 +51,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public filteredTagsObservable: Observable<Tag[]> = of([]); public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update';
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [COMMA, ENTER];
public tags: Tag[] = []; public tagsAvailable: Tag[] = [];
public total = 0; public total = 0;
public typesTranslationMap = new Map<Type, string>(); public typesTranslationMap = new Map<Type, string>();
public Validators = Validators; public Validators = Validators;
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.mode = this.data.activity.id ? 'update' : 'create';
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
@ -79,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms; this.platforms = platforms;
this.tags = tags.map(({ id, name }) => { this.tagsAvailable = tags.map(({ id, name }) => {
return { return {
id, id,
name: translate(name) name: translate(name)
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [ accountId: [
this.data.accounts.length === 1 && !this.data.activity?.accountId this.data.accounts.length === 1 &&
!this.data.activity?.accountId &&
this.mode === 'create'
? this.data.accounts[0].id ? this.data.accounts[0].id
: this.data.activity?.accountId, : this.data.activity?.accountId,
Validators.required Validators.required
@ -283,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
].valueChanges.pipe( ].valueChanges.pipe(
startWith(this.activityForm.get('tags').value), startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => { map((aTags: Tag[] | null) => {
return aTags ? this.filterTags(aTags) : this.tags.slice(); return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
}) })
); );
@ -437,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public onAddTag(event: MatAutocompleteSelectedEvent) { public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([ this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []), ...(this.activityForm.get('tags').value ?? []),
this.tags.find(({ id }) => { this.tagsAvailable.find(({ id }) => {
return id === event.option.value; return id === event.option.value;
}) })
]); ]);
this.tagInput.nativeElement.value = ''; this.tagInput.nativeElement.value = '';
} }
@ -479,18 +484,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}; };
try { try {
if (this.data.activity.id) { if (this.mode === 'create') {
(activity as UpdateOrderDto).id = this.data.activity.id;
await validateObjectForForm({
classDto: UpdateOrderDto,
form: this.activityForm,
ignoreFields: ['dataSource', 'date'],
object: activity as UpdateOrderDto
});
this.dialogRef.close(activity as UpdateOrderDto);
} else {
(activity as CreateOrderDto).updateAccountBalance = (activity as CreateOrderDto).updateAccountBalance =
this.activityForm.get('updateAccountBalance').value; this.activityForm.get('updateAccountBalance').value;
@ -502,6 +496,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}); });
this.dialogRef.close(activity as CreateOrderDto); this.dialogRef.close(activity as CreateOrderDto);
} else {
(activity as UpdateOrderDto).id = this.data.activity.id;
await validateObjectForForm({
classDto: UpdateOrderDto,
form: this.activityForm,
ignoreFields: ['dataSource', 'date'],
object: activity as UpdateOrderDto
});
this.dialogRef.close(activity as UpdateOrderDto);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -514,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
} }
private filterTags(aTags: Tag[]) { private filterTags(aTags: Tag[]) {
const tagIds = aTags.map((tag) => { const tagIds = aTags.map(({ id }) => {
return tag.id; return id;
}); });
return this.tags.filter((tag) => { return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(tag.id); return !tagIds.includes(id);
}); });
} }

View File

@ -4,10 +4,10 @@
(keyup.enter)="activityForm.valid && onSubmit()" (keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
@if (data.activity.id) { @if (mode === 'create') {
<h1 i18n mat-dialog-title>Update activity</h1>
} @else {
<h1 i18n mat-dialog-title>Add activity</h1> <h1 i18n mat-dialog-title>Add activity</h1>
} @else {
<h1 i18n mat-dialog-title>Update activity</h1>
} }
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div class="mb-3"> <div class="mb-3">
@ -76,16 +76,17 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{ 'mb-3': data.activity.id }"> <div [ngClass]="{ 'mb-3': mode === 'update' }">
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100" class="w-100"
[ngClass]="{ 'mb-1 without-hint': !data.activity.id }" [ngClass]="{ 'mb-1 without-hint': mode === 'create' }"
> >
<mat-label i18n>Account</mat-label> <mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId"> <mat-select formControlName="accountId">
@if ( @if (
!activityForm.get('accountId').hasValidator(Validators.required) !activityForm.get('accountId').hasValidator(Validators.required) ||
(!activityForm.get('accountId').value && mode === 'update')
) { ) {
<mat-option [value]="null" /> <mat-option [value]="null" />
} }
@ -106,7 +107,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3" [ngClass]="{ 'd-none': data.activity.id }"> <div class="mb-3" [ngClass]="{ 'd-none': mode === 'update' }">
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n <mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
>Update Cash Balance</mat-checkbox >Update Cash Balance</mat-checkbox
> >
@ -377,11 +378,11 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }"> <div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList> <mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag) { @for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row <mat-chip-row
matChipRemove matChipRemove
[removable]="true" [removable]="true"
@ -403,7 +404,7 @@
#autocompleteTags="matAutocomplete" #autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)" (optionSelected)="onAddTag($event)"
> >
@for (tag of filteredTagsObservable | async; track tag) { @for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id"> <mat-option [value]="tag.id">
{{ tag.name }} {{ tag.name }}
</mat-option> </mat-option>

View File

@ -38,6 +38,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
export class ImportActivitiesDialog implements OnDestroy { export class ImportActivitiesDialog implements OnDestroy {
public accounts: CreateAccountDto[] = []; public accounts: CreateAccountDto[] = [];
public activities: Activity[] = []; public activities: Activity[] = [];
public assetProfileForm: FormGroup;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public details: any[] = []; public details: any[] = [];
public deviceType: string; public deviceType: string;
@ -53,7 +54,6 @@ export class ImportActivitiesDialog implements OnDestroy {
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation; public stepperOrientation: StepperOrientation;
public totalItems: number; public totalItems: number;
public uniqueAssetForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -73,8 +73,8 @@ export class ImportActivitiesDialog implements OnDestroy {
this.stepperOrientation = this.stepperOrientation =
this.deviceType === 'mobile' ? 'vertical' : 'horizontal'; this.deviceType === 'mobile' ? 'vertical' : 'horizontal';
this.uniqueAssetForm = this.formBuilder.group({ this.assetProfileForm = this.formBuilder.group({
uniqueAsset: [undefined, Validators.required] assetProfileIdentifier: [undefined, Validators.required]
}); });
if ( if (
@ -85,7 +85,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.dialogTitle = $localize`Import Dividends`; this.dialogTitle = $localize`Import Dividends`;
this.mode = 'DIVIDEND'; this.mode = 'DIVIDEND';
this.uniqueAssetForm.get('uniqueAsset').disable(); this.assetProfileForm.get('assetProfileIdentifier').disable();
this.dataService this.dataService
.fetchPortfolioHoldings({ .fetchPortfolioHoldings({
@ -102,7 +102,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.holdings = sortBy(holdings, ({ name }) => { this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
}); });
this.uniqueAssetForm.get('uniqueAsset').enable(); this.assetProfileForm.get('assetProfileIdentifier').enable();
this.isLoading = false; this.isLoading = false;
@ -167,10 +167,11 @@ export class ImportActivitiesDialog implements OnDestroy {
} }
public onLoadDividends(aStepper: MatStepper) { public onLoadDividends(aStepper: MatStepper) {
this.uniqueAssetForm.get('uniqueAsset').disable(); this.assetProfileForm.get('assetProfileIdentifier').disable();
const { dataSource, symbol } = const { dataSource, symbol } = this.assetProfileForm.get(
this.uniqueAssetForm.get('uniqueAsset').value; 'assetProfileIdentifier'
).value;
this.dataService this.dataService
.fetchDividendsImport({ .fetchDividendsImport({
@ -193,7 +194,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.details = []; this.details = [];
this.errorMessages = []; this.errorMessages = [];
this.importStep = ImportStep.SELECT_ACTIVITIES; this.importStep = ImportStep.SELECT_ACTIVITIES;
this.uniqueAssetForm.get('uniqueAsset').enable(); this.assetProfileForm.get('assetProfileIdentifier').enable();
aStepper.reset(); aStepper.reset();
} }

View File

@ -25,14 +25,14 @@
<div class="pt-3"> <div class="pt-3">
@if (mode === 'DIVIDEND') { @if (mode === 'DIVIDEND') {
<form <form
[formGroup]="uniqueAssetForm" [formGroup]="assetProfileForm"
(ngSubmit)="onLoadDividends(stepper)" (ngSubmit)="onLoadDividends(stepper)"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label> <mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset"> <mat-select formControlName="assetProfileIdentifier">
<mat-select-trigger>{{ <mat-select-trigger>{{
uniqueAssetForm.get('uniqueAsset')?.value?.name assetProfileForm.get('assetProfileIdentifier')?.value?.name
}}</mat-select-trigger> }}</mat-select-trigger>
@for (holding of holdings; track holding) { @for (holding of holdings; track holding) {
<mat-option <mat-option
@ -63,7 +63,7 @@
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!uniqueAssetForm.valid" [disabled]="!assetProfileForm.valid"
> >
<span i18n>Load Dividends</span> <span i18n>Load Dividends</span>
</button> </button>

View File

@ -6,12 +6,13 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
Holding, Holding,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -161,7 +162,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.initialize(); this.initialize();
} }
public onAccountChartClicked({ symbol }: UniqueAsset) { public onAccountChartClicked({ symbol }: AssetProfileIdentifier) {
if (symbol && symbol !== UNKNOWN_KEY) { if (symbol && symbol !== UNKNOWN_KEY) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId: symbol, accountDetailDialog: true } queryParams: { accountId: symbol, accountDetailDialog: true }
@ -169,7 +170,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) { public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -584,7 +585,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
data: <AccountDetailDialogParams>{ data: <AccountDetailDialogParams>{
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
!this.user?.settings?.isRestrictedView
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -253,7 +253,8 @@
} @else { } @else {
{{ baseCurrency }}&nbsp;<strong>{{ price }}</strong> {{ baseCurrency }}&nbsp;<strong>{{ price }}</strong>
} }
&nbsp;<span i18n>per year</span></span <span>&nbsp;</span>
<span i18n>per year</span></span
> >
</p> </p>
@if ( @if (

View File

@ -152,7 +152,7 @@
</div> </div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-10 offset-md-1"> <div class="col-md-10 offset-md-1">
<h2 class="h4 mb-1 text-center"> <h2 class="h4 mb-1 text-center" i18n>
Would you like to <strong>refine</strong> your Would you like to <strong>refine</strong> your
<strong>personal investment strategy</strong>? <strong>personal investment strategy</strong>?
</h2> </h2>

View File

@ -1,6 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
'/' + $localize`resources`, '/' + $localize`resources`,
'personal-finance-tools' 'personal-finance-tools'
]; ];
public tags: string[];
public constructor( public constructor(
private dataService: DataService, private dataService: DataService,
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,
region: $localize`Global`, regions: [$localize`Global`],
slogan: 'Open Source Wealth Management', slogan: 'Open Source Wealth Management',
useAnonymously: true useAnonymously: true
}; };
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
this.product2 = personalFinanceTools.find(({ key }) => { this.product2 = personalFinanceTools.find(({ key }) => {
return key === this.route.snapshot.data['key']; return key === this.route.snapshot.data['key'];
}); });
if (this.product2.origin) {
this.product2.origin = translate(this.product2.origin);
}
if (this.product2.regions) {
this.product2.regions = this.product2.regions.map((region) => {
return translate(region);
});
}
this.tags = [
this.product1.name,
this.product2.name,
$localize`Alternative`,
$localize`App`,
$localize`Budgeting`,
$localize`Community`,
$localize`Family Office`,
`Fintech`,
$localize`Investment`,
$localize`Investor`,
$localize`Open Source`,
`OSS`,
$localize`Personal Finance`,
$localize`Privacy`,
$localize`Portfolio`,
$localize`Software`,
$localize`Tool`,
$localize`User Experience`,
$localize`Wealth`,
$localize`Wealth Management`,
`WealthTech`
].sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
} }
} }

View File

@ -80,8 +80,24 @@
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.region }}</td> <td class="mat-mdc-cell px-1 py-2">
<td class="mat-mdc-cell px-1 py-2">{{ product2.region }}</td> @for (
region of product1.regions;
track region;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product2.regions;
track region;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
}
</td>
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>
@ -236,69 +252,11 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
@for (tag of tags; track tag) {
<li class="list-inline-item"> <li class="list-inline-item">
<span class="badge badge-light">Alternative</span> <span class="badge badge-light">{{ tag }}</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budgeting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Family Office</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">{{ product1.name }}</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investor</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Tool</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">User Experience</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">WealthTech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">{{ product2.name }}</span>
</li> </li>
}
</ul> </ul>
</section> </section>
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">

View File

@ -7,13 +7,13 @@ import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
AdminData, AdminData,
AdminJobs, AdminJobs,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
@ -35,7 +35,7 @@ export class AdminService {
private http: HttpClient private http: HttpClient
) {} ) {}
public addAssetProfile({ dataSource, symbol }: UniqueAsset) { public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.post<void>( return this.http.post<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/profile-data/${dataSource}/${symbol}`,
null null
@ -62,7 +62,7 @@ export class AdminService {
return this.http.delete<void>(`/api/v1/platform/${aId}`); return this.http.delete<void>(`/api/v1/platform/${aId}`);
} }
public deleteProfileData({ dataSource, symbol }: UniqueAsset) { public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>( return this.http.delete<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}` `/api/v1/admin/profile-data/${dataSource}/${symbol}`
); );
@ -167,7 +167,10 @@ export class AdminService {
return this.http.post<void>('/api/v1/admin/gather/profile-data', {}); return this.http.post<void>('/api/v1/admin/gather/profile-data', {});
} }
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public gatherProfileDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.http.post<void>( return this.http.post<void>(
`/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
{} {}
@ -178,7 +181,7 @@ export class AdminService {
dataSource, dataSource,
date, date,
symbol symbol
}: UniqueAsset & { }: AssetProfileIdentifier & {
date?: Date; date?: Date;
}) { }) {
let url = `/api/v1/admin/gather/${dataSource}/${symbol}`; let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
@ -217,7 +220,7 @@ export class AdminService {
symbol, symbol,
symbolMapping, symbolMapping,
url url
}: UniqueAsset & UpdateAssetProfileDto) { }: AssetProfileIdentifier & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>( return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/profile-data/${dataSource}/${symbol}`,
{ {
@ -272,7 +275,7 @@ export class AdminService {
dataSource, dataSource,
scraperConfiguration, scraperConfiguration,
symbol symbol
}: UniqueAsset & UpdateAssetProfileDto['scraperConfiguration']) { }: AssetProfileIdentifier & UpdateAssetProfileDto['scraperConfiguration']) {
return this.http.post<any>( return this.http.post<any>(
`/api/v1/admin/market-data/${dataSource}/${symbol}/test`, `/api/v1/admin/market-data/${dataSource}/${symbol}/test`,
{ {

View File

@ -4,7 +4,10 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import {
Activities,
Activity
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
@ -20,6 +23,7 @@ import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AdminMarketDataDetails, AdminMarketDataDetails,
AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
Export, Export,
@ -34,7 +38,6 @@ import {
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
@ -47,7 +50,8 @@ import { SortDirection } from '@angular/material/sort';
import { import {
AccountBalance, AccountBalance,
DataSource, DataSource,
Order as OrderModel Order as OrderModel,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash'; import { cloneDeep, groupBy, isNumber } from 'lodash';
@ -211,6 +215,17 @@ export class DataService {
); );
} }
public fetchActivity(aActivityId: string) {
return this.http.get<Activity>(`/api/v1/order/${aActivityId}`).pipe(
map((activity) => {
activity.createdAt = parseISO(<string>(<unknown>activity.createdAt));
activity.date = parseISO(<string>(<unknown>activity.date));
return activity;
})
);
}
public fetchDividends({ public fetchDividends({
filters, filters,
groupBy = 'month', groupBy = 'month',
@ -229,7 +244,7 @@ export class DataService {
}); });
} }
public fetchDividendsImport({ dataSource, symbol }: UniqueAsset) { public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.get<ImportResponse>( return this.http.get<ImportResponse>(
`/api/v1/import/dividends/${dataSource}/${symbol}` `/api/v1/import/dividends/${dataSource}/${symbol}`
); );
@ -269,7 +284,7 @@ export class DataService {
return this.http.delete<any>(`/api/v1/order/${aId}`); return this.http.delete<any>(`/api/v1/order/${aId}`);
} }
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) { public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`); return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
} }
@ -288,7 +303,7 @@ export class DataService {
public fetchAsset({ public fetchAsset({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Observable<AdminMarketDataDetails> { }: AssetProfileIdentifier): Observable<AdminMarketDataDetails> {
return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe( return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
@ -307,7 +322,7 @@ export class DataService {
}: { }: {
range: DateRange; range: DateRange;
startDate: Date; startDate: Date;
} & UniqueAsset): Observable<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
let params = new HttpParams(); let params = new HttpParams();
if (range) { if (range) {
@ -629,7 +644,7 @@ export class DataService {
); );
} }
public postBenchmark(benchmark: UniqueAsset) { public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post(`/api/v1/benchmark`, benchmark); return this.http.post(`/api/v1/benchmark`, benchmark);
} }
@ -649,6 +664,17 @@ export class DataService {
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData); return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
} }
public putHoldingTags({
dataSource,
symbol,
tags
}: { tags: Tag[] } & AssetProfileIdentifier) {
return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
{ tags }
);
}
public putOrder(aOrder: UpdateOrderDto) { public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
} }

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 323 87"><defs><style>.cls-1{opacity:0.1;}</style></defs><g class="cls-1"><path d="M201.13,52.72a30.78,30.78,0,0,1,3-13.73,21.72,21.72,0,0,1,8.55-9.33,24.91,24.91,0,0,1,12.94-3.31q10.48,0,17.11,6.42t7.39,17.41l.1,3.55q0,11.91-6.66,19.11T225.68,80q-11.2,0-17.88-7.17t-6.67-19.53Zm13.83,1q0,7.38,2.77,11.29a10,10,0,0,0,15.79.05q2.83-3.87,2.83-12.34,0-7.25-2.83-11.22a9.18,9.18,0,0,0-7.94-4,9,9,0,0,0-7.85,4C215.88,44.09,215,48.18,215,53.7Z"/></g><path d="M35.37,64.78a4.47,4.47,0,0,0-2.52-4,28.42,28.42,0,0,0-8.06-2.6Q6.33,54.3,6.32,42.48A14.24,14.24,0,0,1,12,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.61,14.61,0,0,1,5.91,12H34.84a6.71,6.71,0,0,0-1.91-4.9q-1.92-1.93-6-1.94a8.26,8.26,0,0,0-5.4,1.58,5,5,0,0,0-1.92,4,4.27,4.27,0,0,0,2.18,3.71A22.51,22.51,0,0,0,29.15,48a60.73,60.73,0,0,1,8.7,2.32q11,4,11,13.93a13.49,13.49,0,0,1-6.08,11.46Q36.66,80,27,80a27.37,27.37,0,0,1-11.56-2.32,19.36,19.36,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73H17.8a7.23,7.23,0,0,0,2.73,5.64,10.82,10.82,0,0,0,6.8,2,10,10,0,0,0,6-1.5A4.69,4.69,0,0,0,35.37,64.78Z"/><path d="M79.58,80q-11.39,0-18.54-7T53.89,54.44V53.1a31.28,31.28,0,0,1,3-14,22.21,22.21,0,0,1,8.54-9.47,24,24,0,0,1,12.61-3.33q10.62,0,16.73,6.7t6.1,19V57.7h-33a12.79,12.79,0,0,0,4,8.13,12.24,12.24,0,0,0,8.54,3.06q8,0,12.49-5.79l6.8,7.61a20.71,20.71,0,0,1-8.43,6.87A27.65,27.65,0,0,1,79.58,80ZM78,37.5a8.62,8.62,0,0,0-6.67,2.79,14.43,14.43,0,0,0-3.28,8H87.29V47.16A10.34,10.34,0,0,0,84.8,40,8.94,8.94,0,0,0,78,37.5Z"/><path d="M121.36,79.09H107.48V5.59h13.88Z"/><path d="M134.57,79.09V37.46h-7.71V27.31h7.71v-4.4q0-8.71,5-13.52t14-4.81a32.27,32.27,0,0,1,7,1l-.14,10.72a17.57,17.57,0,0,0-4.22-.43q-7.8,0-7.8,7.32v4.16h10.29V37.46H148.44V79.09Z"/><path d="M177.44,33a17.28,17.28,0,0,1,13.83-6.61q16.85,0,17.09,19.58V79.09H194.53V46.31q0-4.45-1.92-6.58t-6.36-2.13q-6.08,0-8.81,4.69v36.8H163.62V5.59h13.82Z"/><path d="M271.22,64.78a4.48,4.48,0,0,0-2.51-4,28.42,28.42,0,0,0-8.06-2.6q-18.48-3.88-18.47-15.7A14.22,14.22,0,0,1,247.9,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.64,14.64,0,0,1,5.91,12H270.7a6.68,6.68,0,0,0-1.92-4.9c-1.27-1.29-3.27-1.94-6-1.94a8.31,8.31,0,0,0-5.41,1.58,5,5,0,0,0-1.91,4,4.27,4.27,0,0,0,2.18,3.71A22.52,22.52,0,0,0,265,48a60.54,60.54,0,0,1,8.71,2.32q11,4,11,13.93a13.51,13.51,0,0,1-6.08,11.46Q272.52,80,262.9,80a27.37,27.37,0,0,1-11.56-2.32,19.24,19.24,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73h13.11a7.23,7.23,0,0,0,2.73,5.64,10.79,10.79,0,0,0,6.79,2,10.07,10.07,0,0,0,6-1.5A4.71,4.71,0,0,0,271.22,64.78Z"/><path d="M308.17,14.58V27.31H317V37.46h-8.85V63.3a6.13,6.13,0,0,0,1.1,4.11c.73.83,2.13,1.25,4.21,1.25a22.24,22.24,0,0,0,4.06-.34V78.8A28.42,28.42,0,0,1,309.17,80q-14.55,0-14.83-14.69V37.46h-7.56V27.31h7.56V14.58Z"/><path d="M222.41,75.64a4.12,4.12,0,0,1,1.07-2.85,3.87,3.87,0,0,1,3-1.17,4,4,0,0,1,3,1.17,4.06,4.06,0,0,1,1.1,2.85,3.68,3.68,0,0,1-1.1,2.75,4.14,4.14,0,0,1-3,1.08,4,4,0,0,1-3-1.08A3.73,3.73,0,0,1,222.41,75.64Z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -7,10 +7,16 @@
{ {
"sizes": "192x192", "sizes": "192x192",
"src": "/assets/android-chrome-192x192.png", "src": "/assets/android-chrome-192x192.png",
"type": "image/png", "type": "image/png"
"purpose": "any maskable"
}, },
{ {
"purpose": "any",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
},
{
"purpose": "maskable",
"sizes": "512x512", "sizes": "512x512",
"src": "/assets/android-chrome-512x512.png", "src": "/assets/android-chrome-512x512.png",
"type": "image/png" "type": "image/png"
@ -21,5 +27,5 @@
"short_name": "Ghostfolio", "short_name": "Ghostfolio",
"start_url": "/en/", "start_url": "/en/",
"theme_color": "#FFFFFF", "theme_color": "#FFFFFF",
"url": "https://www.ghostfol.io" "url": "https://ghostfol.io"
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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