Compare commits

..

42 Commits

Author SHA1 Message Date
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
423bd92b89 Release 2.94.0 (#3556) 2024-07-09 18:44:53 +02:00
5dc331e386 Feature/improve language localization for de 20240709 (#3555)
* Update translations

* Update changelog
2024-07-09 18:43:20 +02:00
744dc51dcd Bugfix/fix pagination issue in activities endpoint by adding secondary sort criterion (#3554)
* Add id as secondary sort criterion to ensure consistent ordering

* Update changelog
2024-07-09 18:42:03 +02:00
b0c53d050a Feature/harmonize delete labels in admin market data (#3552) 2024-07-09 18:20:25 +02:00
91 changed files with 44301 additions and 23117 deletions

View File

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

4
.gitignore vendored
View File

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

2
.nvmrc
View File

@ -1 +1 @@
v18
v20

View File

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

View File

@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
## 2.93.0 - 2024-07-07
### Added
@ -4783,7 +4861,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the attribute `precision` in the value component
- Added the attribute `precision` to the value component
### Fixed

View File

@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `npx nx migrate --run-migrations`
### Prisma
#### Access database via GUI
Run `yarn database:gui`
Run `npm run database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`
Run `npm run database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration
Run `yarn prisma migrate dev --name added_job_title`
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

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
WORKDIR /ghostfolio
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,31 +32,31 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:production
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
FROM node:18-slim
FROM node:20-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
RUN apt update && apt install -y \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh

View File

@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `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_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` |
| `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) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
| `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_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` |
| `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) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -149,16 +149,15 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Node.js](https://nodejs.org/en/download) (version 20+)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `yarn install`
1. Run `npm install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema
1. Run `npm run database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development))
1. Open https://localhost:4200/en in your browser
@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
#### Debug
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `yarn start:server`
Run `npm run start:server`
### Start Client
Run `yarn start:client` and open https://localhost:4200/en in your browser
Run `npm run start:client` and open https://localhost:4200/en in your browser
### Start _Storybook_
Run `yarn start:storybook`
Run `npm run start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
npm run database:push
```
## Testing
Run `yarn test`
Run `npm test`
## Public API
@ -233,18 +232,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
| Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
| Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
#### Response

View File

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

View File

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

View File

@ -27,12 +27,13 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
} from '@prisma/client';
@ -212,98 +213,113 @@ export class AdminService {
}
}
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
count = marketData.length;
}
count = marketData.length;
return {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({
@ -431,6 +447,52 @@ export class AdminService {
return response;
}
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
Order: {
where: {
User: {
Subscription: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.Order > 0;
}
}
}
}
});
});
return new PrismaClient().$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,

View File

@ -72,9 +72,13 @@ export class ImportService {
})
]);
const accounts = orders.map((order) => {
return order.Account;
});
const accounts = orders
.filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;

View File

@ -291,7 +291,8 @@ export class OrderService {
withExcludedAccounts?: boolean;
}): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
{ date: 'asc' },
{ id: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
@ -311,10 +312,14 @@ export class OrderService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
@ -356,6 +361,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) {
where.tags = {
some: {
@ -367,7 +396,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
}
if (types) {

View File

@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
@ -340,10 +346,11 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
feeInBaseCurrency,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.fee) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee
currentPosition.feeInBaseCurrency
);
}

View File

@ -496,9 +496,6 @@ export class PortfolioController {
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
throw new HttpException(
@ -508,6 +505,11 @@ export class PortfolioController {
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}

View File

@ -237,6 +237,7 @@ export class UserService {
currentPermissions = without(
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess
);

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import {
DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
@ -62,9 +63,22 @@ export class DataGatheringService {
}
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols({
dataGatheringItems,
dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
@ -138,7 +152,7 @@ export class DataGatheringService {
});
if (!uniqueAssets) {
uniqueAssets = await this.getUniqueAssets();
uniqueAssets = await this.getAllAssetProfileIdentifiers();
}
if (uniqueAssets.length <= 0) {
@ -270,7 +284,7 @@ export class DataGatheringService {
);
}
public async getUniqueAssets(): Promise<UniqueAsset[]> {
public async getAllAssetProfileIdentifiers(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
@ -290,73 +304,83 @@ export class DataGatheringService {
});
}
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
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 = (
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
UniqueAsset[]
> {
return (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }],
where: {
date: { gt: startDate },
date: { gt: subDays(resetHours(new Date()), 7) },
state: 'CLOSE'
}
})
)
.filter((group) => {
return group._count >= 6;
.filter(({ _count }) => {
return _count >= 6;
})
.map((group) => {
return group.symbol;
.map(({ dataSource, 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 }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
!symbolsWithCompleteMarketData.includes(symbol) &&
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
}) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
);
})
.map((symbolProfile) => {
return {
...symbolProfile,
date: startDate
date: subDays(resetHours(new Date()), 7)
};
});
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return !symbolsWithCompleteMarketData.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {

View File

@ -14,7 +14,12 @@ import {
DERIVED_CURRENCIES,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getCurrencyFromSymbol,
getStartOfUtcDate,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
@ -423,13 +428,18 @@ export class DataProviderService {
continue;
}
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const symbols = dataGatheringItems
.filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
.map(({ symbol }) => {
return symbol;
});
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;

View File

@ -91,6 +91,40 @@ export class SymbolProfileService {
});
}
public async getSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}) {
return this.prismaService.symbolProfile.findMany({
include: {
Order: {
include: {
User: true
}
}
},
orderBy: [{ symbol: 'asc' }],
where: {
Order: withUserSubscription
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
}
});
}
public updateSymbolProfile({
assetClass,
assetSubClass,

View File

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

View File

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

View File

@ -6,8 +6,14 @@ import {
ghostfolioScraperApiSymbolPrefix
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import {
Filter,
InfoItem,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n';
import { SelectionModel } from '@angular/cdk/collections';
@ -97,22 +103,11 @@ export class AdminMarketDataComponent
new MatTableDataSource();
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns = [
'select',
'nameWithSymbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activitiesCount',
'marketDataItemCount',
'sectorsCount',
'countriesCount',
'comment',
'actions'
];
public displayedColumns: string[] = [];
public filters$ = new Subject<Filter[]>();
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public isLoading = false;
public isUUID = isUUID;
public placeholder = '';
@ -134,6 +129,33 @@ export class AdminMarketDataComponent
private router: Router,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.displayedColumns = [
'select',
'nameWithSymbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activitiesCount',
'marketDataItemCount',
'sectorsCount',
'countriesCount'
];
if (this.hasPermissionForSubscription) {
this.displayedColumns.push('isUsedByUsersWithSubscription');
}
this.displayedColumns.push('comment');
this.displayedColumns.push('actions');
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {

View File

@ -144,6 +144,15 @@
</td>
</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">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -178,7 +187,7 @@
[disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()"
>
<ng-container i18n>Delete Asset Profiles</ng-container>
<ng-container i18n>Delete Profiles</ng-container>
</button>
</mat-menu>
</th>

View File

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

View File

@ -31,7 +31,7 @@ export class AdminMarketDataService {
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
const confirmation = confirm(
$localize`Do you really want to delete these asset profiles?`
$localize`Do you really want to delete these profiles?`
);
if (confirmation) {
@ -42,7 +42,7 @@ export class AdminMarketDataService {
forkJoin(deleteRequests)
.pipe(
catchError(() => {
alert($localize`Oops! Could not delete asset profiles.`);
alert($localize`Oops! Could not delete profiles.`);
return EMPTY;
}),

View File

@ -12,11 +12,7 @@
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">
<gf-value
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
/>
<gf-value [locale]="user?.settings?.locale" [value]="userCount" />
</div>
</div>
<div class="d-flex my-3">
@ -24,7 +20,6 @@
<div class="w-50">
<gf-value
[locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount"
/>
@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 { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
@ -84,18 +85,22 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public historicalDataItems: LineChartItem[];
public investment: number;
public investmentPrecision = 2;
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
@ -161,10 +166,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
) {
this.dividendInBaseCurrencyPrecision = 0;
}
this.dividendYieldPercentWithCurrencyEffect =
dividendYieldPercentWithCurrencyEffect;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({
@ -178,17 +193,58 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
};
}
);
this.investment = investment;
if (
this.data.deviceType === 'mobile' &&
this.investment >= NUMERICAL_PRECISION_THRESHOLD
) {
this.investmentPrecision = 0;
}
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformanceWithCurrencyEffectPrecision = 0;
}
this.quantity = quantity;
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
} else if (this.quantity >= 10000000) {
this.quantityPrecision = 0;
}
}
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {};
this.SymbolProfile = SymbolProfile;
@ -282,18 +338,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();
}
);

View File

@ -47,6 +47,7 @@
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[precision]="netPerformanceWithCurrencyEffectPrecision"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value
@ -58,6 +59,7 @@
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[precision]="netPerformancePrecision"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
@ -160,6 +162,7 @@
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="investmentPrecision"
[unit]="data.baseCurrency"
[value]="investment"
>Investment</gf-value
@ -172,6 +175,7 @@
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value

View File

@ -1,11 +1,21 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import {
HoldingType,
HoldingViewMode,
ToggleOption
} from '@ghostfolio/common/types';
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 { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -18,6 +28,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
@ -26,6 +37,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User;
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
private unsubscribeSubject = new Subject<void>();
@ -34,6 +46,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private userService: UserService
) {}
@ -53,20 +66,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions,
permissions.accessHoldingsChart
);
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.initialize();
this.changeDetectorRef.markForCheck();
}
@ -76,15 +86,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType;
this.holdings = undefined;
this.initialize();
}
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}
}
public ngOnDestroy() {
@ -104,4 +114,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
range: this.user?.settings?.dateRange
});
}
private initialize() {
this.viewModeFormControl.disable();
if (
this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE'
) {
this.viewModeFormControl.enable();
} else if (this.holdingType === 'CLOSED') {
this.viewModeFormControl.setValue('TABLE');
}
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -6,33 +6,62 @@
</div>
<div class="row">
<div class="col-lg">
<div class="d-flex justify-content-end">
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions"
(change)="onChangeHoldingType($event.value)"
/>
</div>
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
<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
class="d-none d-lg-block"
[defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions"
(change)="onChangeHoldingType($event.value)"
/>
</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
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div>
}
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import {
LineChartItem,
PortfolioPerformance,
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isAllTimeLow: boolean;
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public precision = 2;
public showDetails = false;
public unit: string;
public user: User;
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.showDetails =
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.subscribe(() => {
this.update();
});
this.showDetails =
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
}
public onChangeDateRange(dateRange: DateRange) {
@ -134,6 +136,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
}
);
if (
this.deviceType === 'mobile' &&
this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD
) {
this.precision = 0;
}
this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck();

View File

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

View File

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

View File

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

View File

@ -70,6 +70,14 @@
"
>
<mat-option [value]="null" />
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="de"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) {
@ -95,10 +103,12 @@
>)</mat-option
>
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
<!--
<mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
@ -196,25 +206,23 @@
/>
</div>
</div>
@if (hasPermissionToUpdateUserSettings) {
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
/>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
}
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
/>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container>

View File

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

View File

@ -186,6 +186,14 @@
title="Sackgeld.com Apps für ein höheres Sackgeld"
></a>
</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">
<a
class="d-block logo logo-sourceforge mask"

View File

@ -80,6 +80,11 @@
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 {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
@ -131,6 +136,7 @@
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {

View File

@ -51,6 +51,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false;
public isToday = isToday;
public mode: 'create' | 'update';
public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = [];
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) {}
public ngOnInit() {
this.mode = this.data.activity.id ? 'update' : 'create';
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({
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.activity?.accountId,
Validators.required
@ -479,18 +483,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
};
try {
if (this.data.activity.id) {
(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 {
if (this.mode === 'create') {
(activity as CreateOrderDto).updateAccountBalance =
this.activityForm.get('updateAccountBalance').value;
@ -502,6 +495,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
});
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) {
console.error(error);

View File

@ -4,10 +4,10 @@
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
@if (data.activity.id) {
<h1 i18n mat-dialog-title>Update activity</h1>
} @else {
@if (mode === 'create') {
<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="mb-3">
@ -76,16 +76,17 @@
</mat-select>
</mat-form-field>
</div>
<div [ngClass]="{ 'mb-3': data.activity.id }">
<div [ngClass]="{ 'mb-3': mode === 'update' }">
<mat-form-field
appearance="outline"
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-select formControlName="accountId">
@if (
!activityForm.get('accountId').hasValidator(Validators.required)
!activityForm.get('accountId').hasValidator(Validators.required) ||
(!activityForm.get('accountId').value && mode === 'update')
) {
<mat-option [value]="null" />
}
@ -106,7 +107,7 @@
</mat-select>
</mat-form-field>
</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
>Update Cash Balance</mat-checkbox
>

View File

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

View File

@ -152,7 +152,7 @@
</div>
<div class="row my-5">
<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
<strong>personal investment strategy</strong>?
</h2>

View File

@ -1,6 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
'/' + $localize`resources`,
'personal-finance-tools'
];
public tags: string[];
public constructor(
private dataService: DataService,
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
],
name: 'Ghostfolio',
origin: $localize`Switzerland`,
region: $localize`Global`,
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
};
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
this.product2 = personalFinanceTools.find(({ 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 class="mat-mdc-row">
<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">{{ product2.region }}</td>
<td class="mat-mdc-cell px-1 py-2">
@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 class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
@ -236,69 +252,11 @@
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Alternative</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>
@for (tag of tags; track tag) {
<li class="list-inline-item">
<span class="badge badge-light">{{ tag }}</span>
</li>
}
</ul>
</section>
<nav aria-label="breadcrumb">

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",
"src": "/assets/android-chrome-192x192.png",
"type": "image/png",
"purpose": "any maskable"
"type": "image/png"
},
{
"purpose": "any",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
},
{
"purpose": "maskable",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
@ -21,5 +27,5 @@
"short_name": "Ghostfolio",
"start_url": "/en/",
"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

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

View File

@ -1,11 +1,11 @@
#!/bin/bash
# Will check if "yarn format" is run before executing.
# Will check if "npm run format" is run before executing.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
echo "Running yarn format"
echo "Running npm run format"
# Run the command and loop over its output
FILES_TO_STAGE=""
@ -14,13 +14,13 @@ while IFS= read -r line; do
# Process each line here
((i++))
if [ $i -le 2 ]; then
continue
continue
fi
if [[ $line == Done* ]]; then
break
break
fi
FILES_TO_STAGE="$FILES_TO_STAGE $line"
done < <(yarn format )
done < <(npm run format)
git add $FILES_TO_STAGE
echo "Files formatted. Committing..."

View File

@ -91,6 +91,8 @@ export const HEADER_KEY_TOKEN = 'Authorization';
export const MAX_CHART_ITEMS = 365;
export const MAX_TOP_HOLDINGS = 50;
export const NUMERICAL_PRECISION_THRESHOLD = 100000;
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID';
export const PROPERTY_COUNTRIES_OF_SUBSCRIBERS = 'COUNTRIES_OF_SUBSCRIBERS';
@ -130,6 +132,7 @@ export const REPLACE_NAME_PARTS = [
];
export const SUPPORTED_LANGUAGE_CODES = [
'ca',
'de',
'en',
'es',

View File

@ -11,7 +11,7 @@ import {
parseISO,
subDays
} from 'date-fns';
import { de, es, fr, it, nl, pl, pt, tr, zhCN } from 'date-fns/locale';
import { ca, de, es, fr, it, nl, pl, pt, tr, zhCN } from 'date-fns/locale';
import {
DEFAULT_CURRENCY,
@ -171,7 +171,9 @@ export function getCurrencyFromSymbol(aSymbol = '') {
}
export function getDateFnsLocale(aLanguageCode: string) {
if (aLanguageCode === 'de') {
if (aLanguageCode === 'ca') {
return ca;
} else if (aLanguageCode === 'de') {
return de;
} else if (aLanguageCode === 'es') {
return es;

View File

@ -15,6 +15,7 @@ export interface AdminMarketDataItem {
date: Date;
id: string;
isBenchmark?: boolean;
isUsedByUsersWithSubscription?: boolean;
marketDataItemCount: number;
name: string;
sectorsCount: number;

View File

@ -10,7 +10,7 @@ export interface Product {
note?: string;
origin?: string;
pricingPerYear?: string;
region?: string;
regions?: string[];
slogan?: string;
useAnonymously?: boolean;
}

View File

@ -24,6 +24,10 @@ export class TimelinePosition {
@Type(() => Big)
fee: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
feeInBaseCurrency: Big;
firstBuyDate: string;
@Transform(transformToBig, { toClassOnly: true })

View File

@ -5,6 +5,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAssistant: 'accessAssistant',
accessHoldingsChart: 'accessHoldingsChart',
createAccess: 'createAccess',
createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance',
@ -47,6 +48,7 @@ export function getPermissions(aRole: Role): string[] {
return [
permissions.accessAdminControl,
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAccount,
permissions.createAccountBalance,
@ -72,11 +74,16 @@ export function getPermissions(aRole: Role): string[] {
];
case 'DEMO':
return [permissions.accessAssistant, permissions.createUserAccount];
return [
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createUserAccount
];
case 'USER':
return [
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAccount,
permissions.createAccountBalance,

View File

@ -76,7 +76,7 @@ export const personalFinanceTools: Product[] = [
key: 'capmon',
name: 'CapMon.org',
origin: `Germany`,
note: 'CapMon.org has discontinued in 2023',
note: 'CapMon.org was discontinued in 2023',
slogan: 'Next Generation Assets Tracking'
},
{
@ -223,7 +223,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: false,
key: 'intuit-mint',
name: 'Intuit Mint',
note: 'Intuit Mint has discontinued in 2023',
note: 'Intuit Mint was discontinued in 2023',
origin: `United States`,
pricingPerYear: '$60',
slogan: 'Managing money, made simple'
@ -277,7 +277,7 @@ export const personalFinanceTools: Product[] = [
name: 'markets.sh',
origin: `Germany`,
pricingPerYear: '€168',
region: `Global`,
regions: [`Global`],
slogan: 'Track your investments'
},
{
@ -286,10 +286,10 @@ export const personalFinanceTools: Product[] = [
key: 'maybe-finance',
languages: ['English'],
name: 'Maybe Finance',
note: 'Maybe Finance has discontinued in 2023',
note: 'Maybe Finance was discontinued in 2023',
origin: `United States`,
pricingPerYear: '$145',
region: `United States`,
regions: [`United States`],
slogan: 'Your financial future, in your control'
},
{
@ -300,7 +300,7 @@ export const personalFinanceTools: Product[] = [
name: 'Merlin',
origin: `United States`,
pricingPerYear: '$204',
region: 'Canada, United States',
regions: ['Canada', 'United States'],
slogan: 'The smartest way to track your crypto'
},
{
@ -340,7 +340,7 @@ export const personalFinanceTools: Product[] = [
note: 'Originally named as Tresor One',
origin: `Germany`,
pricingPerYear: '€88',
region: 'Austria, Germany, Switzerland',
regions: ['Austria', 'Germany', 'Switzerland'],
slogan: 'Dein Vermögen immer im Blick'
},
{
@ -351,6 +351,18 @@ export const personalFinanceTools: Product[] = [
origin: `Italy`,
slogan: 'Your Personal Finance Hub'
},
{
founded: 2008,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'pocketsmith',
languages: ['English'],
name: 'PocketSmith',
origin: `New Zealand`,
pricingPerYear: '$120',
regions: [`Global`],
slogan: 'Know where your money is going'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
@ -374,7 +386,7 @@ export const personalFinanceTools: Product[] = [
hasFreePlan: true,
key: 'portfoloo',
name: 'Portfoloo',
note: 'Portfoloo has discontinued',
note: 'Portfoloo was discontinued',
slogan:
'Free Stock Portfolio Tracker with unlimited portfolio and stocks for DIY investors'
},
@ -432,14 +444,14 @@ export const personalFinanceTools: Product[] = [
name: 'Sharesight',
origin: `New Zealand`,
pricingPerYear: '$135',
region: `Global`,
regions: [`Global`],
slogan: 'Stock Portfolio Tracker'
},
{
hasFreePlan: true,
key: 'sharesmaster',
name: 'SharesMaster',
note: 'SharesMaster has discontinued',
note: 'SharesMaster was discontinued',
slogan: 'Free Stock Portfolio Tracker'
},
{
@ -480,7 +492,7 @@ export const personalFinanceTools: Product[] = [
key: 'stockmarketeye',
name: 'StockMarketEye',
origin: `France`,
note: 'StockMarketEye has discontinued in 2023',
note: 'StockMarketEye was discontinued in 2023',
slogan: 'A Powerful Portfolio & Investment Tracking App'
},
{
@ -581,8 +593,9 @@ export const personalFinanceTools: Product[] = [
key: 'yeekatee',
languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'],
name: 'yeekatee',
note: 'yeekatee was discontinued in 2024',
origin: `Switzerland`,
region: `Global`,
regions: [`Global`],
slogan: 'Connect. Share. Invest.'
},
{

View File

@ -0,0 +1 @@
export type HoldingViewMode = 'CHART' | 'TABLE';

View File

@ -8,6 +8,7 @@ import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import type { GroupBy } from './group-by.type';
import type { HoldingType } from './holding-type.type';
import type { HoldingViewMode } from './holding-view-mode.type';
import type { MarketAdvanced } from './market-advanced.type';
import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type';
@ -30,6 +31,7 @@ export type {
Granularity,
GroupBy,
HoldingType,
HoldingViewMode,
Market,
MarketAdvanced,
MarketDataPreset,

View File

@ -11,10 +11,10 @@ const locales = {
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source or Ghostfolio Basic easily`,
EMERGENCY_FUND: $localize`Emergency Fund`,
Global: $localize`Global`,
GRANT: $localize`Grant`,
HIGHER_RISK: $localize`Higher Risk`,
IMPORT_ACTIVITY_ERROR_IS_DUPLICATE: $localize`This activity already exists.`,
Japan: $localize`Japan`,
LOWER_RISK: $localize`Lower Risk`,
MONTH: $localize`Month`,
MONTHS: $localize`Months`,
@ -65,6 +65,28 @@ const locales = {
Oceania: $localize`Oceania`,
'South America': $localize`South America`,
// Countries
Australia: $localize`Australia`,
Austria: $localize`Austria`,
Belgium: $localize`Belgium`,
Bulgaria: $localize`Bulgaria`,
Canada: $localize`Canada`,
'Czech Republic': $localize`Czech Republic`,
Finland: $localize`Finland`,
France: $localize`France`,
Germany: $localize`Germany`,
India: $localize`India`,
Italy: $localize`Italy`,
Japan: $localize`Japan`,
Netherlands: $localize`Netherlands`,
'New Zealand': $localize`New Zealand`,
Poland: $localize`Poland`,
Romania: $localize`Romania`,
'South Africa': $localize`South Africa`,
Switzerland: $localize`Switzerland`,
Thailand: $localize`Thailand`,
'United States': $localize`United States`,
// Fear and Greed Index
EXTREME_FEAR: $localize`Extreme Fear`,
EXTREME_GREED: $localize`Extreme Greed`,

View File

@ -29,6 +29,21 @@ import ChartDataLabels from 'chartjs-plugin-datalabels';
import * as Color from 'color';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
const {
blue,
cyan,
grape,
green,
indigo,
lime,
orange,
pink,
red,
teal,
violet,
yellow
} = require('open-color');
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule],
@ -350,24 +365,20 @@ export class GfPortfolioProportionChartComponent
this.isLoading = false;
}
/**
* Color palette, inspired by https://yeun.github.io/open-color
*/
private getColorPalette() {
//
return [
'#329af0', // blue 5
'#20c997', // teal 5
'#94d82d', // lime 5
'#ff922b', // orange 5
'#f06595', // pink 5
'#845ef7', // violet 5
'#5c7cfa', // indigo 5
'#22b8cf', // cyan 5
'#51cf66', // green 5
'#fcc419', // yellow 5
'#ff6b6b', // red 5
'#cc5de8' // grape 5
blue[5],
teal[5],
lime[5],
orange[5],
pink[5],
violet[5],
indigo[5],
cyan[5],
green[5],
yellow[5],
red[5],
grape[5]
];
}

View File

@ -0,0 +1 @@
export * from './treemap-chart.component';

View File

@ -0,0 +1,13 @@
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="h-100"
[theme]="{
height: '100%'
}"
/>
}
<canvas
#chartCanvas
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@ -0,0 +1,4 @@
:host {
aspect-ratio: 16 / 9;
display: block;
}

View File

@ -0,0 +1,168 @@
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { DataSource } from '@prisma/client';
import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js';
import { Chart } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
const { gray, green, red } = require('open-color');
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule],
selector: 'gf-treemap-chart',
standalone: true,
styleUrls: ['./treemap-chart.component.scss'],
templateUrl: './treemap-chart.component.html'
})
export class GfTreemapChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() cursor: string;
@Input() holdings: PortfolioPosition[];
@Output() treemapChartClicked = new EventEmitter<UniqueAsset>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'treemap'>;
public isLoading = true;
public constructor() {
Chart.register(LinearScale, TreemapController, TreemapElement);
}
public ngAfterViewInit() {
if (this.holdings) {
this.initialize();
}
}
public ngOnChanges() {
if (this.holdings) {
this.initialize();
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
const data: ChartConfiguration['data'] = <any>{
datasets: [
{
backgroundColor(ctx) {
const netPerformancePercentWithCurrencyEffect =
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
if (netPerformancePercentWithCurrencyEffect > 0.03) {
return green[9];
} else if (netPerformancePercentWithCurrencyEffect > 0.02) {
return green[7];
} else if (netPerformancePercentWithCurrencyEffect > 0.01) {
return green[5];
} else if (netPerformancePercentWithCurrencyEffect > 0) {
return green[3];
} else if (netPerformancePercentWithCurrencyEffect === 0) {
return gray[3];
} else if (netPerformancePercentWithCurrencyEffect > -0.01) {
return red[3];
} else if (netPerformancePercentWithCurrencyEffect > -0.02) {
return red[5];
} else if (netPerformancePercentWithCurrencyEffect > -0.03) {
return red[7];
} else {
return red[9];
}
},
borderRadius: 4,
key: 'allocationInPercentage',
labels: {
align: 'left',
color: ['white'],
display: true,
font: [{ size: 14 }, { size: 11 }, { lineHeight: 2, size: 14 }],
formatter(ctx) {
const netPerformancePercentWithCurrencyEffect =
ctx.raw._data.netPerformancePercentWithCurrencyEffect;
return [
ctx.raw._data.name,
ctx.raw._data.symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
];
},
position: 'top'
},
spacing: 1,
tree: this.holdings
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: <unknown>{
animation: false,
onClick: (event, activeElements) => {
try {
const dataIndex = activeElements[0].index;
const datasetIndex = activeElements[0].datasetIndex;
const dataset = orderBy(
event.chart.data.datasets[datasetIndex].tree,
['allocationInPercentage'],
['desc']
);
const dataSource: DataSource = dataset[dataIndex].dataSource;
const symbol: string = dataset[dataIndex].symbol;
this.treemapChartClicked.emit({ dataSource, symbol });
} catch {}
},
onHover: (event, chartElement) => {
if (this.cursor) {
event.native.target.style.cursor = chartElement[0]
? this.cursor
: 'default';
}
},
plugins: {
tooltip: {
enabled: false
}
}
},
type: 'treemap'
});
}
}
this.isLoading = false;
}
}

View File

@ -29,7 +29,7 @@ export class GfValueComponent implements OnChanges {
@Input() isPercent = false;
@Input() locale: string;
@Input() position = '';
@Input() precision: number | undefined;
@Input() precision: number;
@Input() size: 'large' | 'medium' | 'small' = 'small';
@Input() subLabel = '';
@Input() unit = '';
@ -58,8 +58,10 @@ export class GfValueComponent implements OnChanges {
this.formattedValue = this.absoluteValue.toLocaleString(
this.locale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2
maximumFractionDigits:
this.precision >= 0 ? this.precision : 2,
minimumFractionDigits:
this.precision >= 0 ? this.precision : 2
}
);
} catch {}
@ -68,8 +70,10 @@ export class GfValueComponent implements OnChanges {
this.formattedValue = (this.absoluteValue * 100).toLocaleString(
this.locale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2
maximumFractionDigits:
this.precision >= 0 ? this.precision : 2,
minimumFractionDigits:
this.precision >= 0 ? this.precision : 2
}
);
} catch {}
@ -77,8 +81,8 @@ export class GfValueComponent implements OnChanges {
} else if (this.isCurrency) {
try {
this.formattedValue = this.value?.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
maximumFractionDigits: this.precision >= 0 ? this.precision : 2,
minimumFractionDigits: this.precision >= 0 ? this.precision : 2
});
} catch {}
} else if (this.isPercent) {
@ -86,12 +90,12 @@ export class GfValueComponent implements OnChanges {
this.formattedValue = (this.value * 100).toLocaleString(
this.locale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2
maximumFractionDigits: this.precision >= 0 ? this.precision : 2,
minimumFractionDigits: this.precision >= 0 ? this.precision : 2
}
);
} catch {}
} else if (this.precision || this.precision === 0) {
} else if (this.precision >= 0) {
try {
this.formattedValue = this.value?.toLocaleString(this.locale, {
maximumFractionDigits: this.precision,
@ -136,6 +140,7 @@ export class GfValueComponent implements OnChanges {
this.isNumber = false;
this.isString = false;
this.locale = this.locale || getLocale();
this.precision = this.precision >= 0 ? this.precision : undefined;
this.useAbsoluteValue = false;
}
}

34039
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.93.0",
"version": "2.99.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -15,7 +15,7 @@
"affected:test": "nx affected:test",
"analyze:client": "nx run client:build:production --stats-json && webpack-bundle-analyzer -p 1234 dist/apps/client/en/stats.json",
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && yarn replace-placeholders-in-build",
"build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && npm run replace-placeholders-in-build",
"build:storybook": "nx run ui:build-storybook",
"database:format-schema": "prisma format",
"database:generate-typings": "prisma generate",
@ -24,7 +24,7 @@
"database:migrate": "prisma migrate deploy",
"database:push": "prisma db push",
"database:seed": "prisma db seed",
"database:setup": "yarn database:push && yarn database:seed",
"database:setup": "npm run database:push && npm run database:seed",
"database:validate-schema": "prisma validate",
"dep-graph": "nx dep-graph",
"e2e": "ng e2e",
@ -40,10 +40,10 @@
"replace-placeholders-in-build": "node ./replace.build.js",
"start": "node dist/apps/api/main",
"start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o",
"start:production": "yarn database:migrate && yarn database:seed && node main",
"start:production": "npm run database:migrate && npm run database:seed && node main",
"start:server": "nx run api:copy-assets && nx run api:serve --watch",
"start:storybook": "nx run ui:storybook",
"test": "yarn test:api && yarn test:common",
"test": "npm run test:api && npm run test:common",
"test:api": "npx dotenv-cli -e .env.example -- nx test api",
"test:common": "npx dotenv-cli -e .env.example -- nx test common",
"test:single": "nx run api:test --test-file portfolio-calculator-novn-buy-and-sell.spec.ts",
@ -54,17 +54,17 @@
"workspace-generator": "nx workspace-generator"
},
"dependencies": {
"@angular/animations": "18.0.4",
"@angular/cdk": "18.0.4",
"@angular/common": "18.0.4",
"@angular/compiler": "18.0.4",
"@angular/core": "18.0.4",
"@angular/forms": "18.0.4",
"@angular/material": "18.0.4",
"@angular/platform-browser": "18.0.4",
"@angular/platform-browser-dynamic": "18.0.4",
"@angular/router": "18.0.4",
"@angular/service-worker": "18.0.4",
"@angular/animations": "18.1.1",
"@angular/cdk": "18.1.1",
"@angular/common": "18.1.1",
"@angular/compiler": "18.1.1",
"@angular/core": "18.1.1",
"@angular/forms": "18.1.1",
"@angular/material": "18.1.1",
"@angular/platform-browser": "18.1.1",
"@angular/platform-browser-dynamic": "18.1.1",
"@angular/router": "18.1.1",
"@angular/service-worker": "18.1.1",
"@codewithdan/observable-store": "2.2.15",
"@dfinity/agent": "0.15.7",
"@dfinity/auth-client": "0.15.7",
@ -84,7 +84,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.16.1",
"@prisma/client": "5.17.0",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0",
@ -97,6 +97,7 @@
"cache-manager-redis-store": "2.0.0",
"chart.js": "4.2.0",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-treemap": "2.3.1",
"chartjs-plugin-annotation": "2.1.2",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0-rc.12",
@ -115,18 +116,19 @@
"ionicons": "7.4.0",
"jsonpath": "1.1.1",
"lodash": "4.17.21",
"marked": "13.0.0",
"marked": "12.0.2",
"ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "2.12.0",
"ngx-device-detector": "8.0.0",
"ngx-markdown": "18.0.0",
"ngx-skeleton-loader": "7.0.0",
"ngx-stripe": "18.0.0",
"open-color": "1.9.1",
"papaparse": "5.3.1",
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"prisma": "5.16.1",
"prisma": "5.17.0",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "15.11.0",
@ -137,34 +139,34 @@
"zone.js": "0.14.7"
},
"devDependencies": {
"@angular-devkit/build-angular": "18.0.5",
"@angular-devkit/core": "18.0.5",
"@angular-devkit/schematics": "18.0.5",
"@angular-eslint/eslint-plugin": "18.0.1",
"@angular-eslint/eslint-plugin-template": "18.0.1",
"@angular-eslint/template-parser": "18.0.1",
"@angular/cli": "18.0.5",
"@angular/compiler-cli": "18.0.4",
"@angular/language-service": "18.0.4",
"@angular/localize": "18.0.4",
"@angular/pwa": "18.0.5",
"@angular-devkit/build-angular": "18.1.1",
"@angular-devkit/core": "18.1.1",
"@angular-devkit/schematics": "18.1.1",
"@angular-eslint/eslint-plugin": "18.1.0",
"@angular-eslint/eslint-plugin-template": "18.1.0",
"@angular-eslint/template-parser": "18.1.0",
"@angular/cli": "18.1.1",
"@angular/compiler-cli": "18.1.1",
"@angular/language-service": "18.1.1",
"@angular/localize": "18.1.1",
"@angular/pwa": "18.1.1",
"@nestjs/schematics": "10.0.1",
"@nestjs/testing": "10.1.3",
"@nx/angular": "19.4.0",
"@nx/cypress": "19.4.0",
"@nx/eslint-plugin": "19.4.0",
"@nx/jest": "19.4.0",
"@nx/js": "19.4.0",
"@nx/nest": "19.4.0",
"@nx/node": "19.4.0",
"@nx/storybook": "19.4.0",
"@nx/web": "19.4.0",
"@nx/workspace": "19.4.0",
"@schematics/angular": "18.0.3",
"@nx/angular": "19.5.1",
"@nx/cypress": "19.5.1",
"@nx/eslint-plugin": "19.5.1",
"@nx/jest": "19.5.1",
"@nx/js": "19.5.1",
"@nx/nest": "19.5.1",
"@nx/node": "19.5.1",
"@nx/storybook": "19.5.1",
"@nx/web": "19.5.1",
"@nx/workspace": "19.5.1",
"@schematics/angular": "18.1.1",
"@simplewebauthn/types": "9.0.1",
"@storybook/addon-essentials": "7.6.5",
"@storybook/angular": "7.6.5",
"@storybook/core-server": "7.6.5",
"@storybook/addon-essentials": "8.2.6",
"@storybook/angular": "8.2.6",
"@storybook/core-server": "8.2.6",
"@trivago/prettier-plugin-sort-imports": "4.3.0",
"@types/big.js": "6.2.2",
"@types/body-parser": "1.19.5",
@ -173,37 +175,37 @@
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "29.4.4",
"@types/lodash": "4.17.0",
"@types/node": "18.16.9",
"@types/node": "20.14.10",
"@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.16",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"codelyzer": "6.0.1",
"cypress": "6.2.1",
"eslint": "8.56.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-storybook": "0.6.15",
"jest": "29.4.3",
"jest-environment-jsdom": "29.4.3",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0",
"nx": "19.4.0",
"prettier": "3.3.1",
"nx": "19.5.1",
"prettier": "3.3.3",
"prettier-plugin-organize-attributes": "1.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",
"shx": "0.3.4",
"storybook": "7.0.9",
"storybook": "8.2.6",
"ts-jest": "29.1.0",
"ts-node": "10.9.1",
"ts-node": "10.9.2",
"tslib": "2.6.0",
"typescript": "5.4.4",
"typescript": "5.5.3",
"webpack-bundle-analyzer": "4.10.1"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"prisma": {
"seed": "node prisma/seed.js"

20779
yarn.lock

File diff suppressed because it is too large Load Diff