Compare commits

..

97 Commits

Author SHA1 Message Date
6f7e370fca Release 1.279.0 (#2066) 2023-06-10 12:21:11 +02:00
b4a126280f Bugfix/fix public page (#2065)
* Check for user in request because of public page

* Update changelog
2023-06-10 12:19:34 +02:00
2d009aacc4 Bugfix/handle value nullifcation for undefined object (#2064)
* Handle undefined object

* Update changelog
2023-06-10 12:01:26 +02:00
9116443305 Feature/support note in accounts (#2063)
* Add support for a note in accounts

* Update changelog
2023-06-10 12:01:13 +02:00
0adaf12a01 Add new French translations (#2057)
* Add new French translations

* Update changelog

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-06-10 11:19:33 +02:00
b6562b6e2c Release 1.278.0 (#2062) 2023-06-09 21:14:38 +02:00
b0a4b09ef5 Feature/extract license to dedicated tab (#2061)
* Extract license to tab on about page

* Update changelog
2023-06-09 21:13:05 +02:00
ad8b9ad333 Bugfix/improve spacing in bechmark comparator (#2060)
* Improve spacing

* Update changelog
2023-06-09 20:52:51 +02:00
809956f210 Feature/display markets overview link in footer based on permission (#2059)
* Add check for permission

* Update changelog
2023-06-09 20:01:24 +02:00
6077bfa754 Feature/change direction of ellipsis icon to horizontal in tables (#2055)
* Change direction of ellipsis icon to horizontal

* Update changelog
2023-06-09 19:39:14 +02:00
09498bd804 Feature/refresh cryptocurrencies list 20230606 (#2049)
* Update cryptocurrencies.json

* Update changelog
2023-06-09 18:55:24 +02:00
fd84f4ec14 Change to routerLink (#2058) 2023-06-09 17:37:52 +02:00
c711a11d6e Feature/upgrade node.js from version 16 to 18 (#2053)
* Upgrade to Node.js 18

* Update changelog
2023-06-09 17:28:05 +02:00
8232b05f62 Feature/extend activity cloning by quantity (#2054)
* Allow to clone quantity

* Update changelog
2023-06-09 16:14:40 +02:00
0ea66aebcb Release 1.277.0 (#2052) 2023-06-07 17:35:19 +02:00
64087de3fc Bugfix/fix date format parsing in activities import (#2051)
* Fix date format parsing

* Update changelog
2023-06-07 17:33:35 +02:00
7082ff12f8 Feature/add semantic list structure to header navigation (#2044)
* Add semantic list structure (ul and li elements)

* Update changelog
2023-06-07 17:22:09 +02:00
1c7d92e15e Harmonize Slack community links (#2047) 2023-06-06 09:23:32 +02:00
a53461d257 Feature/migrate currency to unit in value component (#2043)
* Migrate currency to unit

* Update locales
2023-06-04 21:46:49 +02:00
d630fb900d Feature/add investment streaks (#2042)
* Add investment streaks

* Current streak
* Longest streak

* Add unit to value component

* Update changelog
2023-06-04 09:35:58 +02:00
51e8555fa5 Update link (#2040) 2023-06-04 09:30:35 +02:00
9db675b955 Feature/improve get symbol data endpoint (#2041)
* Add default value to query parameter

* Update changelog
2023-06-03 19:32:48 +02:00
45bd8ed029 Release 1.276.0 (#2039) 2023-06-03 13:39:30 +02:00
707fd31550 Feature/add changefreq to sitemap.xml (#2038)
* Add changefreq to /markets and /open

* Update changelog
2023-06-03 13:37:50 +02:00
6e5f0086a1 Bugfix/fix price when creating subscription (#2037)
* Fix price

* Update changelog
2023-06-03 13:26:05 +02:00
97bcd8ff49 Feature/enforce yyyy instead of yy as date format in activities import (#2036)
* Enforce yyyy (instead of yy)

* Update changelog
2023-06-03 10:19:50 +02:00
1809fc8a80 Feature/update url of ghostfolio slack channel (#2035)
* Update Slack url

* Update changelog
2023-06-03 08:28:36 +02:00
beb24f9bd4 Revert "Update Slack url" (#2034)
* Revert "Update Slack url (#2025)"

This reverts commit c48670ccdc.
2023-06-03 08:28:07 +02:00
ae57a188f5 Feature/improve tab navigations (#2031)
* Improve tab navigations

* Update changelog
2023-06-03 07:44:59 +02:00
23db85e940 Feature/add tabs to about page (#2030)
* Add tabs to about page

* Update changelog
2023-06-02 21:23:57 +02:00
bd8bb1a36a Feature/remove ghostfolio in numbers section from about page (#2029)
* Remove Ghostfolio in Numbers section

* Update changelog
2023-06-01 19:38:38 +02:00
c48670ccdc Update Slack url (#2025) 2023-05-31 17:10:59 +02:00
fc019002e2 Release 1.275.0 (#2028) 2023-05-30 21:22:25 +02:00
4282cb66b8 Feature/add localized versions to footer (#2027)
* Add links to localized versions

* Update changelog
2023-05-30 21:20:44 +02:00
1d0ba5fe4b Bugfix/fix indirect calculation in exchange rate service for specific date (#2026)
* Fix indirect calculation

* Update changelog
2023-05-30 21:05:53 +02:00
24cfb26c5b Feature/improve language localization for german 20230529 2 (#2022)
* Improve locales

* Update changelog
2023-05-30 20:48:09 +02:00
26a70aa208 Release 1.274.0 (#2021) 2023-05-29 19:43:52 +02:00
ab7e050066 Feature/extend footer by navigation (#2020)
* Extend footer

* Update changelog
2023-05-29 19:41:21 +02:00
26b1fd6572 Feature/localize meta description (#2019)
* Add localized meta description

* Update changelog
2023-05-29 18:02:18 +02:00
d7e682b65a Feature/extend testimonial section (#2018)
* Add testimonials

* Update changelog
2023-05-29 14:16:17 +02:00
f589ccb775 Feature/improve language localization for german 20230529 (#2017)
* Improve locales

* Update changelog
2023-05-29 13:24:18 +02:00
206b6567fd Feature/improve activities import dialog (#2016)
* Improve activities import dialog

* Update changelog
2023-05-29 07:45:55 +02:00
6857e0314f Feature/add support for localized routes in spanish (#2015)
* Add support for localized routes in Spanish

* Update changelog
2023-05-28 22:48:27 +02:00
c8682a7393 Release 1.273.0 (#2014) 2023-05-28 10:31:37 +02:00
144b6b2211 Bugfix/handle undefined in decode data source (#2013)
* Handle undefined data source

* Update changelog
2023-05-28 10:29:52 +02:00
16a5ace4be Introduced stepper to import activities (#1990)
* Introduced stepper to import activities

* Update changelog
2023-05-28 10:21:07 +02:00
b24ddc30c9 Add localized routes for fr, it and nl (#2012) 2023-05-28 10:18:39 +02:00
19333ab084 Fix warnings (#2011) 2023-05-27 10:48:10 +02:00
7529a7a26c Feature/support localized routes (#2009)
* Support localized routes

* Update changelog
2023-05-27 09:53:48 +02:00
21ebaae6ef Add Cloud vs. Self-hosted (#2010) 2023-05-27 09:47:58 +02:00
3bc8b3c836 Feature/add link to manage benchmarks in benchmark comparator component (#2007)
* Add link to manage benchmarks

* Update changelog
2023-05-27 09:34:02 +02:00
bb9415cc15 Release 1.272.0 (#2005) 2023-05-26 20:43:09 +02:00
b3baeb8a5d Feature/support case insensitive names in portfolio proportion chart (#2004)
* Sum up case insensitive names

* Update changelog
2023-05-26 20:41:33 +02:00
1f393e78f6 Feature/improve error handling in delete user endpoint (#2000)
* Improve error handling

* Update changelog
2023-05-25 17:27:33 +02:00
215f5eafa6 Feature/set asset profile as benchmark (#2002)
* Set asset profile as benchmark

* Update changelog

Co-authored-by: Arghya Ghosh <arghyag5@gmail.com>
2023-05-24 21:22:32 +02:00
1916e5343d Feature/decrease table density (#2001)
* Decrease table density

* Update changelog
2023-05-24 19:34:12 +02:00
fa9863fc54 Feature/upgrade ionicons to version 7.1.0 (#1997)
* Upgrade ionicons to version 7.1.0

* Update changelog
2023-05-23 14:45:21 +02:00
7bf48ef351 Improve style on about page (#1998)
* Center changelog button

* Update changelog
2023-05-22 20:20:16 +02:00
faef3606fd Feature/improve breadcrumb navigation style in blog posts for mobile (#1996)
* Improve style for mobile

* Update changelog
2023-05-21 07:50:45 +02:00
d0ccd4d238 Release 1.271.0 (#1995) 2023-05-20 18:13:41 +02:00
51e3650790 Feature/add blog post unlock your financial potential (#1994)
* Add blog post: Unlock your Financial Potential with Ghostfolio

* Update changelog
2023-05-20 18:12:12 +02:00
db29e2b666 Feature/extend financial modeling prep service (#1989)
* Add getHistorical() and search() logic

* Update changelog
2023-05-20 11:10:07 +02:00
655a68a847 Feature/change uptime to last 90 days (#1993)
* Change uptime to last 90 days

* Update changelog
2023-05-20 11:07:53 +02:00
86296b3591 Feature/improve local number formatting in value component (#1992)
* Improve local number formatting

* Update changelog
2023-05-20 10:53:04 +02:00
73c127f10c Bugfix/fix vertical alignment in gf toggle (#1991)
* Fix alignment

* Update changelog
2023-05-20 10:10:53 +02:00
cf4c981cd9 Release 1.270.1 (#1988) 2023-05-19 15:36:20 +02:00
1b9541b933 Release 1.270.0 (#1987) 2023-05-19 15:11:46 +02:00
5bca8de44e Feature/check for duplicates in dividend import (#1986)
* Check for duplicates in dividend import

* Update changelog
2023-05-19 15:05:29 +02:00
136c4bf50b Bugfix/fix data source transformation in dividends import (#1985)
* Fix data source transformation

* Update changelog
2023-05-19 14:47:20 +02:00
4d700e3b83 Feature/add error message if importing duplicates (#1984)
* Add error messages if import include duplicates

* Update changelog
2023-05-19 13:16:40 +02:00
740fa6fc84 Feature/upgrade prisma to version 4.14.1 (#1983)
* Upgrade prisma to version 4.14.1

* Update changelog
2023-05-19 10:16:08 +02:00
cdb8dc72c7 Feature/improve mobile layout of portfolio summary (#1982)
* Improve layout for mobile

* Update changelog
2023-05-18 22:36:30 +02:00
4b3afb5c97 Feature/extract locales 20230518 (#1980)
* Update locales

* Update changelog
2023-05-18 19:27:58 +02:00
abf208432a Feature/extend account detail dialog by cash balance and equity (#1978)
* Add cash balance and equity

* Update changelog
2023-05-18 19:05:22 +02:00
19e6df4fb2 Fix exception (#1979) 2023-05-18 19:03:27 +02:00
7fc3fff431 Bugfix/fix storybook setup (#1976)
* Fix Storybook setup

* Update changelog
2023-05-18 17:51:38 +02:00
edd690850c Feature/setup open startup page (#1967)
* Setup Open Startup page

* Update changelog
2023-05-18 12:31:36 +02:00
302339e1cd Add Postgres connect_timeout for M1 "Can't reach database" error (#1472)
* Add postgres connect_timeout

* Update changelog
2023-05-18 12:02:07 +02:00
739796bc79 Clean up (#1965) 2023-05-13 18:10:45 +02:00
9c30139b86 FIX #1951: select checkbox state fix for duplicates (#1958)
* FIX #1951: select checkbox state fix for duplicates

* Update changelog
2023-05-13 12:15:11 +02:00
0af528b649 fix(launch.json): set "cwd" to apps/api (#1962) 2023-05-13 11:22:04 +02:00
9636c87a2e Release 1.269.0 (#1961) 2023-05-11 09:54:34 +02:00
ad46fb6d61 Feature/add financial modeling prep as data source type (#1960)
* Add Financial Modeling Prep as new data source type

* Update changelog
2023-05-11 09:52:23 +02:00
8e000baef2 fix update activity, hide update cash balance on edit (#1959)
* Fix update activity, hide update cash balance on edit

* Update changelog
2023-05-11 09:11:44 +02:00
a2e1209196 Feature/introduce admin settings (#1957)
* Introduce admin settings

* Update changelog
2023-05-10 17:56:17 +02:00
ef4a75d1f0 Feature/improve market price of first buy date in chart of position detail dialog (#1956)
* Improve market price on first buy date: market price or average price

* Update changelog
2023-05-09 20:33:15 +02:00
3db20feb54 Release 1.268.0 (#1953) 2023-05-08 22:25:44 +02:00
b9ec381ea2 Feature/upgrade yahoo finance2 to version 2.4.1 (#1952)
* Upgrade yahoo-finance2 to version 2.4.1

* Update changelog
2023-05-08 22:24:12 +02:00
7d6a74a67d Feature/improve check for duplicates in import #1932 (#1940)
* Improve check for duplicates in import

* Update changelog
2023-05-08 21:45:59 +02:00
b923cf7752 Fix docker compose services startup (#1947)
* Fix docker compose services startup

* Update changelog
2023-05-08 20:13:45 +02:00
e32e457ff8 Release 1.267.0 (#1945) 2023-05-07 17:59:04 +02:00
32c1e6b390 Feature/upgrade to nx 16.0 (#1943)
* Upgrade to Nx 16.0

* Update changelog
2023-05-07 17:56:57 +02:00
b42c0c8355 Add comment (#1944) 2023-05-07 17:33:55 +02:00
7140ed8512 Feature/add stripe checkout to pricing page (#1942)
* Add Stripe checkout directly to pricing page

* Update changelog
2023-05-07 16:51:51 +02:00
27d9b075ce Feature/improve interstitial for subscription (#1941)
* Improve style and wording

* Update changelog
2023-05-07 10:38:12 +02:00
5249257dd8 Feature/improve platform managment in admin control (#1939)
* Refactoring, fix tab style, add account count

* Update changelog
2023-05-06 20:24:16 +02:00
606f6159c4 Feature/improve language localization for german 20230506 (#1938)
* Add missing translations

* Update changelog
2023-05-06 17:24:53 +02:00
226 changed files with 13724 additions and 12277 deletions

View File

@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -1,12 +1,12 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nrwl/nx"],
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nrwl/nx/enforce-module-boundaries": [
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
@ -23,12 +23,12 @@
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nrwl/nx/typescript"],
"extends": ["plugin:@nx/typescript"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nrwl/nx/javascript"],
"extends": ["plugin:@nx/javascript"],
"rules": {}
},
{
@ -113,5 +113,6 @@
"radix": "error"
}
}
]
],
"extends": [null, "plugin:storybook/recommended"]
}

View File

@ -36,6 +36,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information -->
- [ ] Cloud
- [ ] Self-hosted
- Ghostfolio Version X.Y.Z
- Browser
- OS

View File

@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
node_version:
- 16
- 18
steps:
- name: Checkout code
uses: actions/checkout@v3

2
.nvmrc
View File

@ -1 +1 @@
v16
v18

View File

@ -1,8 +0,0 @@
module.exports = {
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

View File

@ -1,10 +0,0 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

27
.vscode/launch.json vendored
View File

@ -2,32 +2,33 @@
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest File",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"args": [
"test",
"--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
],
"console": "internalConsole",
"cwd": "${workspaceFolder}",
"console": "internalConsole"
"name": "Debug Jest",
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"request": "launch",
"type": "node"
},
{
"envFile": "${workspaceFolder}/.env",
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/apps/api/src/main.ts",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env",
"name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
],
"console": "integratedTerminal"
"type": "node"
}
]
}

View File

@ -5,6 +5,193 @@ 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).
## 1.279.0 - 2023-06-10
### Added
- Supported a note for accounts
### Changed
- Improved the language localization for French (`fr`)
### Fixed
- Fixed an issue with the value nullification related to the investment streaks
- Fixed an issue in the public page related to the impersonation service
## 1.278.0 - 2023-06-09
### Changed
- Extended the clone functionality of a transaction by the quantity
- Changed the direction of the ellipsis icon in various tables
- Extracted the license to a dedicated tab on the about page
- Displayed the link to the markets overview in the footer based on a permission
- Improved the spacing in the benchmark comparator
- Refreshed the cryptocurrencies list
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
## 1.277.0 - 2023-06-07
### Added
- Added the investment streaks to the analysis page
- Added support for a unit in the value component
- Added a semantic list structure to the header navigation
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
### Fixed
- Fixed an issue with the date format parsing in the activities import
## 1.276.0 - 2023-06-03
### Added
- Added tabs to the about page
- Added the `changefreq` attribute to the sitemap
### Changed
- Improved the routes of the tabs
- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy`
- Updated the URL of the Ghostfolio Slack channel
- Removed the _Ghostfolio in Numbers_ section from the about page
### Fixed
- Fixed an issue with the price when creating a `Subscription`
## 1.275.0 - 2023-05-30
### Changed
- Extended the footer navigation by the localized Ghostfolio versions
- Improved the language localization for German (`de`)
### Fixed
- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency
## 1.274.0 - 2023-05-29
### Added
- Extended the footer by a navigation
- Extended the testimonial section on the landing page
- Added localized meta descriptions
- Added support for localized routes in Spanish (`es`)
### Changed
- Improved the activities import dialog
- Improved the language localization for German (`de`)
## 1.273.0 - 2023-05-28
### Added
- Added a stepper to the activities import dialog
- Added a link to manage the benchmarks to the benchmark comparator
- Added support for localized routes
### Fixed
- Fixed an issue in the data source transformation
## 1.272.0 - 2023-05-26
### Added
- Added support to set an asset profile as a benchmark
### Changed
- Decreased the density of the `@angular/material` tables
- Improved the portfolio proportion chart component by supporting case insensitive names
- Improved the breadcrumb navigation style in the blog post pages for mobile
- Improved the error handling in the delete user endpoint
- Improved the style of the _Changelog & License_ button on the about page
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
## 1.271.0 - 2023-05-20
### Added
- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type
- Added a blog post: _Unlock your Financial Potential with Ghostfolio_
### Changed
- Improved the local number formatting in the value component
- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page
### Fixed
- Fixed the vertical alignment in the toggle component
## 1.270.1 - 2023-05-19
### Added
- Added the cash balance and the value of equity to the account detail dialog
- Added a check for duplicates to the preview step of the import dividends dialog
- Added an error message for duplicates to the preview step of the activities import
- Added a connection timeout to the environment variable `DATABASE_URL`
- Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime
### Changed
- Improved the mobile layout of the portfolio summary tab on the home page
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.13.0` to `4.14.1`
### Fixed
- Improved the _Select all_ activities checkbox state after importing activities including a duplicate
- Fixed an issue with the data source transformation in the import dividends dialog
- Fixed the _Storybook_ setup
## 1.269.0 - 2023-05-11
### Added
- Added `FINANCIAL_MODELING_PREP` as a new data source type
### Changed
- Improved the market price on the first buy date in the chart of the position detail dialog
- Restructured the admin control panel with a new settings tab
### Fixed
- Fixed an error that occurred while editing an activity caused by the cash balance update
## 1.268.0 - 2023-05-08
### Added
- Added `depends_on` and `healthcheck` for the _Postgres_ and _Redis_ services to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
### Changed
- Improved the preview step of the activities import by unchecking duplicates
- Upgraded `yahoo-finance2` from version `2.3.10` to `2.4.1`
## 1.267.0 - 2023-05-07
### Added
- Added support for the _Stripe_ checkout to the pricing page
### Changed
- Improved the management of platforms in the admin control panel
- Improved the style of the interstitial for the subscription
- Improved the language localization for German (`de`)
- Upgraded `Nx` from version `15.9.2` to `16.0.3`
## 1.266.0 - 2023-05-06
### Added
@ -186,7 +373,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme
- Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
- Upgraded `bull` from version `4.10.2` to `4.10.4`

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:16-slim as builder
FROM --platform=$BUILDPLATFORM node:18-slim as builder
# Build application and add additional files
WORKDIR /ghostfolio
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:16-slim
FROM node:18-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*

View File

@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16)
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
@ -232,6 +232,7 @@ 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 | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
@ -268,7 +269,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

View File

@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@nrwl/node:node",
"executor": "@nx/node:node",
"options": {
"buildTarget": "api:build"
}
@ -45,7 +45,7 @@
}
},
"test": {
"executor": "@nrwl/jest:jest",
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsNumber,
@ -6,6 +7,7 @@ import {
IsString,
ValidateIf
} from 'class-validator';
import { isString } from 'lodash';
export class CreateAccountDto {
@IsString()
@ -14,6 +16,13 @@ export class CreateAccountDto {
@IsNumber()
balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;

View File

@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsNumber,
@ -6,6 +7,7 @@ import {
IsString,
ValidateIf
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateAccountDto {
@IsString()
@ -14,6 +16,13 @@ export class UpdateAccountDto {
@IsNumber()
balance: number;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;

View File

@ -1,24 +1,36 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import {
import type {
BenchmarkMarketDataDetails,
BenchmarkResponse
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(private readonly benchmarkService: BenchmarkService) {}
public constructor(
private readonly benchmarkService: BenchmarkService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -45,4 +57,41 @@ export class BenchmarkController {
symbol
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
}

View File

@ -3,6 +3,7 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,

View File

@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService;
beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
benchmarkService = new BenchmarkService(
null,
null,
null,
null,
null,
null,
null
);
});
it('calculateChangeInPercentage', async () => {

View File

@ -2,6 +2,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -11,6 +12,7 @@ import {
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { format } from 'date-fns';
import { uniqBy } from 'lodash';
import ms from 'ms';
@Injectable()
@ -27,6 +30,7 @@ export class BenchmarkService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService,
@ -116,9 +120,9 @@ export class BenchmarkService {
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = (
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
symbolProfileId: string;
}[]) ?? []
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []
).map(({ symbolProfileId }) => {
return symbolProfileId;
});
@ -204,6 +208,43 @@ export class BenchmarkService {
return response;
}
public async addBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id });
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}

View File

@ -21,6 +21,7 @@ export class ExportService {
select: {
accountType: true,
balance: true,
comment: true,
currency: true,
id: true,
isExcluded: true,

View File

@ -19,6 +19,9 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlNl = '';
public indexHtmlPt = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
public constructor(
private readonly configurationService: ConfigurationService
) {
@ -94,6 +97,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
)
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
}
if (
@ -109,6 +119,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate,
featureGraphicPath,
title,
description:
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
languageCode: 'de',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -120,6 +132,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
languageCode: 'es',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -128,7 +142,11 @@ export class FrontendMiddleware implements NestMiddleware {
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send(
this.interpolate(this.indexHtmlFr, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -140,6 +158,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
languageCode: 'it',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -151,6 +171,8 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
languageCode: 'nl',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -159,7 +181,11 @@ export class FrontendMiddleware implements NestMiddleware {
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
@ -171,6 +197,7 @@ export class FrontendMiddleware implements NestMiddleware {
currentDate,
featureGraphicPath,
title,
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')

View File

@ -35,6 +35,8 @@ export class ImportController {
@Post()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import(
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean

View File

@ -1,7 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
Activity,
ActivityError
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -71,8 +74,25 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const isDuplicate = orders.some((activity) => {
return (
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
Account,
error,
quantity,
value,
accountId: Account?.id,
@ -204,9 +224,14 @@ export class ImportService {
userId
});
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,
userId
});
const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => {
return { id: account.id, name: account.name };
({ id, name }) => {
return { id, name };
}
);
@ -221,16 +246,14 @@ export class ImportService {
for (const {
accountId,
comment,
currency,
dataSource,
date: dateString,
date,
error,
fee,
quantity,
symbol,
SymbolProfile: assetProfile,
type,
unitPrice
} of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString));
} of activitiesExtendedWithErrors) {
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
@ -255,6 +278,139 @@ export class ImportService {
createdAt: new Date(),
id: uuidv4(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
comment: assetProfile.comment,
countries: assetProfile.countries,
createdAt: assetProfile.createdAt,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
id: assetProfile.id,
isin: assetProfile.isin,
name: assetProfile.name,
scraperConfiguration: assetProfile.scraperConfiguration,
sectors: assetProfile.sectors,
symbol: assetProfile.currency,
symbolMapping: assetProfile.symbolMapping,
updatedAt: assetProfile.updatedAt,
url: assetProfile.url,
...assetProfiles[assetProfile.symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
};
} else {
if (error) {
continue;
}
order = await this.orderService.createOrder({
comment,
date,
fee,
quantity,
type,
unitPrice,
userId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
},
where: {
dataSource_symbol: {
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
}
}
}
},
updateAccountBalance: false,
User: { connect: { id: userId } }
});
}
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
error,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
assetProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
assetProfile.currency,
userCurrency
)
});
}
return activities;
}
private async extendActivitiesWithErrors({
activitiesDto,
userId
}: {
activitiesDto: Partial<CreateOrderDto>[];
userId: string;
}): Promise<Partial<Activity>[]> {
const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
return activitiesDto.map(
({
accountId,
comment,
currency,
dataSource,
date: dateString,
fee,
quantity,
symbol,
type,
unitPrice
}) => {
const date = parseISO(<string>(<unknown>dateString));
const isDuplicate = existingActivities.some((activity) => {
return (
activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
accountId,
comment,
date,
error,
fee,
quantity,
type,
unitPrice,
SymbolProfile: {
currency,
dataSource,
@ -271,63 +427,11 @@ export class ImportService {
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null,
...assetProfiles[symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
url: null
}
};
} else {
order = await this.orderService.createOrder({
comment,
date,
fee,
quantity,
type,
unitPrice,
userId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
updateAccountBalance: false,
User: { connect: { id: userId } }
});
}
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
currency,
userCurrency
)
});
}
return activities;
);
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
@ -356,33 +460,11 @@ export class ImportService {
const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
{ currency, dataSource, symbol }
] of activitiesDto.entries()) {
const duplicateActivity = existingActivities.find((activity) => {
return (
activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
});
if (duplicateActivity) {
throw new Error(`activities.${index} is a duplicate activity`);
}
if (dataSource !== 'MANUAL') {
const assetProfile = (
await this.dataProviderService.getAssetProfiles([

View File

@ -1,4 +1,5 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -26,6 +27,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
PlatformModule,
PrismaModule,
PropertyModule,
RedisCacheModule,

View File

@ -1,4 +1,5 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -6,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
@ -15,19 +17,22 @@ import {
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import {
InfoItem,
Statistics,
Subscription
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { subDays } from 'date-fns';
import { format, subDays } from 'date-fns';
@Injectable()
export class InfoService {
@ -38,6 +43,7 @@ export class InfoService {
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly platformService: PlatformService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
@ -47,9 +53,12 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
const platforms = (
await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
})
).map(({ id, name }) => {
return { id, name };
});
let systemMessage: string;
@ -110,19 +119,28 @@ export class InfoService {
globalPermissions.push(permissions.createUserAccount);
}
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
return {
...info,
benchmarks,
demoAuthToken,
globalPermissions,
isReadOnlyMode,
platforms,
statistics,
subscriptions,
systemMessage,
tags,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: await this.getDemoAuthToken(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
currencies: this.exchangeRateDataService.getCurrencies()
};
}
@ -286,6 +304,7 @@ export class InfoService {
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
const uptime = await this.getUptime();
statistics = {
activeUsers1d,
@ -294,7 +313,8 @@ export class InfoService {
gitHubContributors,
gitHubStargazers,
newUsers30d,
slackCommunityUsers
slackCommunityUsers,
uptime
};
await this.redisCacheService.set(
@ -318,4 +338,36 @@ export class InfoService {
return JSON.parse(stripeConfig.value);
}
private async getUptime(): Promise<number> {
{
try {
const monitorId = (await this.propertyService.getByKey(
PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string;
const get = bent(
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
'GET',
'json',
200,
{
Authorization: `Bearer ${this.configurationService.get(
'BETTER_UPTIME_API_KEY'
)}`
}
);
const { data } = await get();
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'InfoService');
return undefined;
}
}
}
}

View File

@ -68,5 +68,5 @@ export class CreateOrderDto {
@IsBoolean()
@IsOptional()
updateAccountBalance: boolean;
updateAccountBalance?: boolean;
}

View File

@ -5,8 +5,14 @@ export interface Activities {
}
export interface Activity extends OrderWithAccount {
error?: ActivityError;
feeInBaseCurrency: number;
updateAccountBalance?: boolean;
value: number;
valueInBaseCurrency: number;
}
export interface ActivityError {
code: 'IS_DUPLICATE';
message?: string;
}

View File

@ -67,8 +67,4 @@ export class UpdateOrderDto {
@IsNumber()
unitPrice: number;
@IsBoolean()
@IsOptional()
updateAccountBalance: boolean;
}

View File

@ -30,8 +30,8 @@ export class PlatformController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async getPlatforms(): Promise<Platform[]> {
return this.platformService.getPlatforms();
public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount();
}
@Post()

View File

@ -6,10 +6,6 @@ import { Platform, Prisma } from '@prisma/client';
export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {}
public async getPlatforms(): Promise<Platform[]> {
return this.prismaService.platform.findMany();
}
public async getPlatform(
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
@ -18,6 +14,48 @@ export class PlatformService {
});
}
public async getPlatforms({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.PlatformWhereUniqueInput;
orderBy?: Prisma.PlatformOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.PlatformWhereInput;
} = {}) {
return this.prismaService.platform.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getPlatformsWithAccountCount() {
const platformsWithAccountCount =
await this.prismaService.platform.findMany({
include: {
_count: {
select: { Account: true }
}
}
});
return platformsWithAccountCount.map(({ _count, id, name, url }) => {
return {
id,
name,
url,
accountCount: _count.Account
};
});
}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data

View File

@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2015-02-01', investment: new Big('0') },
{ date: '2015-03-01', investment: new Big('0') },
{ date: '2015-04-01', investment: new Big('0') },
{ date: '2015-05-01', investment: new Big('0') },
{ date: '2015-06-01', investment: new Big('0') },
{ date: '2015-07-01', investment: new Big('0') },
{ date: '2015-08-01', investment: new Big('0') },
{ date: '2015-09-01', investment: new Big('0') },
{ date: '2015-10-01', investment: new Big('0') },
{ date: '2015-11-01', investment: new Big('0') },
{ date: '2015-12-01', investment: new Big('0') },
{ date: '2016-01-01', investment: new Big('0') },
{ date: '2016-02-01', investment: new Big('0') },
{ date: '2016-03-01', investment: new Big('0') },
{ date: '2016-04-01', investment: new Big('0') },
{ date: '2016-05-01', investment: new Big('0') },
{ date: '2016-06-01', investment: new Big('0') },
{ date: '2016-07-01', investment: new Big('0') },
{ date: '2016-08-01', investment: new Big('0') },
{ date: '2016-09-01', investment: new Big('0') },
{ date: '2016-10-01', investment: new Big('0') },
{ date: '2016-11-01', investment: new Big('0') },
{ date: '2016-12-01', investment: new Big('0') },
{ date: '2017-01-01', investment: new Big('0') },
{ date: '2017-02-01', investment: new Big('0') },
{ date: '2017-03-01', investment: new Big('0') },
{ date: '2017-04-01', investment: new Big('0') },
{ date: '2017-05-01', investment: new Big('0') },
{ date: '2017-06-01', investment: new Big('0') },
{ date: '2017-07-01', investment: new Big('0') },
{ date: '2017-08-01', investment: new Big('0') },
{ date: '2017-09-01', investment: new Big('0') },
{ date: '2017-10-01', investment: new Big('0') },
{ date: '2017-11-01', investment: new Big('0') },
{ date: '2017-12-01', investment: new Big('-14156.4') }
]);
});

View File

@ -544,7 +544,7 @@ export class PortfolioCalculator {
return [];
}
const investments = [];
const investments: { date: string; investment: Big }[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
@ -554,13 +554,11 @@ export class PortfolioCalculator {
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) {
// Same group: Add up investments
investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New group: Store previous group and reset
if (currentDate) {
investments.push({
date: format(
@ -595,7 +593,39 @@ export class PortfolioCalculator {
}
}
return investments;
// Fill in the missing dates with investment = 0
const startDate = parseDate(first(this.orders).date);
const endDate = parseDate(last(this.orders).date);
const allDates: string[] = [];
currentDate = startDate;
while (currentDate <= endDate) {
allDates.push(
format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
)
);
currentDate.setMonth(currentDate.getMonth() + 1);
}
for (const date of allDates) {
const existingInvestment = investments.find((investment) => {
return investment.date === date;
});
if (!existingInvestment) {
investments.push({ date, investment: new Big(0) });
}
}
return sortBy(investments, (investment) => {
return investment.date;
});
}
public async calculateTimeline(

View File

@ -258,11 +258,12 @@ export class PortfolioController {
filterByTags
});
let investments = await this.portfolioService.getInvestments({
let { investments, streaks } = await this.portfolioService.getInvestments({
dateRange,
filters,
groupBy,
impersonationId
impersonationId,
savingsRate: this.request.user?.Settings?.settings.savingsRate
});
if (
@ -278,6 +279,11 @@ export class PortfolioController {
date: item.date,
investment: item.investment / maxInvestment
}));
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
}
if (
@ -287,9 +293,14 @@ export class PortfolioController {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
streaks = nullifyValuesInObject(streaks, [
'currentStreak',
'longestStreak'
]);
}
return { investments };
return { investments, streaks };
}
@Get('performance')

View File

@ -28,6 +28,7 @@ import {
Filter,
HistoricalDataItem,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReport,
@ -252,13 +253,15 @@ export class PortfolioService {
dateRange,
filters,
groupBy,
impersonationId
impersonationId,
savingsRate
}: {
dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> {
savingsRate: number;
}): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
@ -276,7 +279,10 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
let investments: InvestmentItem[];
@ -346,9 +352,23 @@ export class PortfolioService {
parseDate(investments[0]?.date)
);
return investments.filter(({ date }) => {
investments = investments.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
let streaks: PortfolioInvestments['streaks'];
if (savingsRate) {
streaks = this.getStreaks({
investments,
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
});
}
return {
investments,
streaks
};
}
public async getChart({
@ -794,16 +814,6 @@ export class PortfolioService {
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
if (historicalData[aSymbol]) {
let j = -1;
for (const [date, { marketPrice }] of Object.entries(
@ -815,11 +825,16 @@ export class PortfolioService {
) {
j++;
}
let currentAveragePrice = 0;
let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find(
(item) => item.symbol === aSymbol
({ symbol }) => {
return symbol === aSymbol;
}
);
if (currentSymbol) {
currentAveragePrice = currentSymbol.quantity.eq(0)
? 0
@ -829,14 +844,25 @@ export class PortfolioService {
historicalDataArray.push({
date,
marketPrice,
averagePrice: currentAveragePrice,
marketPrice:
historicalDataArray.length > 0
? marketPrice
: currentAveragePrice,
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
}
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
return {
@ -1504,6 +1530,28 @@ export class PortfolioService {
return portfolioStart;
}
private getStreaks({
investments,
savingsRate
}: {
investments: InvestmentItem[];
savingsRate: number;
}) {
let currentStreak = 0;
let longestStreak = 0;
for (const { investment } of investments) {
if (investment >= savingsRate) {
currentStreak++;
longestStreak = Math.max(longestStreak, currentStreak);
} else {
currentStreak = 0;
}
}
return { currentStreak, longestStreak };
}
private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
@ -1835,13 +1883,6 @@ export class PortfolioService {
return { accounts, platforms };
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getTotalByType(
orders: OrderWithAccount[],
currency: string,
@ -1868,4 +1909,11 @@ export class PortfolioService {
this.baseCurrency
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
}

View File

@ -1,10 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
@ -101,19 +97,8 @@ export class SubscriptionService {
aCheckoutSessionId
);
let subscriptions: SubscriptionInterface[] = [];
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
subscriptions = [JSON.parse(stripeConfig.value)];
const coupon = subscriptions[0]?.coupon ?? 0;
const price = subscriptions[0]?.price ?? 0;
await this.createSubscription({
price: price - coupon,
price: session.amount_total / 100,
userId: session.client_reference_id
});

View File

@ -60,7 +60,7 @@ export class SymbolController {
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Query('includeHistoricalData') includeHistoricalData?: number
@Query('includeHistoricalData') includeHistoricalData = 0
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(

View File

@ -166,7 +166,7 @@ export class UserService {
this.subscriptionService.getSubscription(Subscription);
if (
Analytics?.activityCount % 25 === 0 &&
Analytics?.activityCount % 20 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
@ -304,21 +304,29 @@ export class UserService {
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
try {
await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
} catch {}
await this.prismaService.account.deleteMany({
where: { userId: where.id }
});
try {
await this.prismaService.account.deleteMany({
where: { userId: where.id }
});
} catch {}
await this.prismaService.analytics.delete({
where: { userId: where.id }
});
try {
await this.prismaService.analytics.delete({
where: { userId: where.id }
});
} catch {}
await this.prismaService.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prismaService.order.deleteMany({
where: { userId: where.id }
});
} catch {}
try {
await this.prismaService.settings.delete({

File diff suppressed because it is too large Load Diff

View File

@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
const object = cloneDeep(aObject);
keys.forEach((key) => {
object[key] = null;
});
if (object) {
keys.forEach((key) => {
object[key] = null;
});
}
return object;
}

View File

@ -6,6 +6,7 @@ import {
Injectable,
NestInterceptor
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
@Injectable()
@ -24,11 +25,24 @@ export class TransformDataSourceInRequestInterceptor<T>
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.dataSource) {
if (request.body.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
} else {
return {
...activity,
dataSource: decodeDataSource(activity.dataSource)
};
}
});
}
if (request.body.dataSource && !DataSource[request.body.dataSource]) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
if (request.params.dataSource) {
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
}
}

View File

@ -15,6 +15,7 @@ export class ConfigurationService {
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD'
}),
BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
@ -29,6 +30,7 @@ export class ConfigurationService {
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
FINANCIAL_MODELING_PREP_API_KEY: str({ default: '' }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),

View File

@ -3,6 +3,7 @@ import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cr
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -32,6 +33,7 @@ import { DataProviderService } from './data-provider.service';
CoinGeckoService,
DataProviderService,
EodHistoricalDataService,
FinancialModelingPrepService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -41,6 +43,7 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
FinancialModelingPrepService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -51,6 +54,7 @@ import { DataProviderService } from './data-provider.service';
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
@ -59,6 +63,7 @@ import { DataProviderService } from './data-provider.service';
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,

View File

@ -11,8 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Granularity } from '@ghostfolio/common/types';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
@ -399,7 +398,10 @@ export class DataProviderService {
}
private isPremiumDataSource(aDataSource: DataSource) {
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
const premiumDataSources: DataSource[] = [
DataSource.EOD_HISTORICAL_DATA,
DataSource.FINANCIAL_MODELING_PREP
];
return premiumDataSources.includes(aDataSource);
}
}

View File

@ -0,0 +1,181 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format, isAfter, isBefore, isSameDay } from 'date-fns';
@Injectable()
export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.apiKey = this.configurationService.get(
'FINANCIAL_MODELING_PREP_API_KEY'
);
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName(),
symbol: aSymbol
};
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const get = bent(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
'GET',
'json',
200
);
const { historical } = await get();
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[aSymbol]: {}
};
for (const { close, date } of historical) {
if (
(isSameDay(parseDate(date), from) ||
isAfter(parseDate(date), from)) &&
isBefore(parseDate(date), to)
) {
result[aSymbol][date] = {
marketPrice: close
};
}
}
return result;
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.FINANCIAL_MODELING_PREP;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
'GET',
'json',
200
);
const response = await get();
for (const { price, symbol } of response) {
results[symbol] = {
currency: this.baseCurrency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price,
marketState: 'delayed'
};
}
} catch (error) {
Logger.error(error, 'FinancialModelingPrepService');
}
return results;
}
public getTestSymbol() {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
'GET',
'json',
200
);
const result = await get();
items = result.map(({ currency, name, symbol }) => {
return {
// TODO: Add assetClass
// TODO: Add assetSubClass
currency,
name,
symbol,
dataSource: this.getName()
};
});
} catch (error) {
Logger.error(error, 'FinancialModelingPrepService');
}
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs'
};
}
}

View File

@ -138,8 +138,7 @@ export class YahooFinanceService implements DataProviderInterface {
marketPrice: this.getConvertedValue({
symbol: aSymbol,
value: historicalItem.close
}),
performance: historicalItem.open - historicalItem.close
})
};
}

View File

@ -186,28 +186,42 @@ export class ExchangeRateDataService {
factor = marketData?.marketPrice;
} else {
// Calculate indirectly via base currency
try {
const [
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
{ marketPrice: marketPriceBaseCurrencyToCurrency }
] = await Promise.all([
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
}),
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
]);
// Calculate the opposite direction
factor =
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
let marketPriceBaseCurrencyFromCurrency: number;
let marketPriceBaseCurrencyToCurrency: number;
try {
if (this.baseCurrency === aFromCurrency) {
marketPriceBaseCurrencyFromCurrency = 1;
} else {
marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
})
)?.marketPrice;
}
} catch {}
try {
if (this.baseCurrency === aToCurrency) {
marketPriceBaseCurrencyToCurrency = 1;
} else {
marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
)?.marketPrice;
}
} catch {}
// Calculate the opposite direction
factor =
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
}
}

View File

@ -12,22 +12,36 @@ export class ImpersonationService {
) {}
public async validateImpersonationId(aId = '') {
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: { id: this.request.user.id },
id: aId
}
});
if (this.request.user) {
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: { id: this.request.user.id },
id: aId
}
});
if (accessObject?.userId) {
return accessObject?.userId;
} else if (
hasPermission(
this.request.user.permissions,
permissions.impersonateAllUsers
)
) {
return aId;
if (accessObject?.userId) {
return accessObject.userId;
} else if (
hasPermission(
this.request.user.permissions,
permissions.impersonateAllUsers
)
) {
return aId;
}
} else {
// Public access
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: null,
User: { id: aId }
}
});
if (accessObject?.userId) {
return accessObject.userId;
}
}
return null;

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string;
@ -16,6 +17,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
EOD_HISTORICAL_DATA_API_KEY: string;
FINANCIAL_MODELING_PREP_API_KEY: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;

View File

@ -23,7 +23,6 @@ export interface IOrder {
export interface IDataProviderHistoricalResponse {
marketPrice: number;
performance?: number;
}
export interface IDataProviderResponse {

View File

@ -5,7 +5,7 @@
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nrwl/cypress:cypress",
"executor": "@nx/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",

View File

@ -11,7 +11,7 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor');
const { preprocessTypescript } = require('@nx/cypress/plugins/preprocessor');
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits

View File

@ -195,7 +195,7 @@
}
},
"test": {
"executor": "@nrwl/jest:jest",
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true

View File

@ -5,25 +5,49 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
import { ModulePreloadService } from './core/module-preload.service';
const routes: Routes = [
{
path: 'about',
...[
'about',
/////
'a-propos',
'informazioni-su',
'over',
'sobre',
'ueber-uns'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
},
{
path: 'about/changelog',
})),
...[
'about/changelog',
/////
'a-propos/changelog',
'informazioni-su/changelog',
'over/changelog',
'sobre/changelog',
'ueber-uns/changelog'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{
path: 'about/privacy-policy',
})),
...[
'about/privacy-policy',
/////
'a-propos/politique-de-confidentialite',
'informazioni-su/informativa-sulla-privacy',
'over/privacybeleid',
'sobre/politica-de-privacidad',
'ueber-uns/datenschutzbestimmungen'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
})),
{
path: 'account',
loadChildren: () =>
@ -48,11 +72,11 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
},
{
path: 'blog',
...['blog'].map((path) => ({
path,
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
})),
{
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
@ -137,34 +161,69 @@ const routes: Routes = [
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
},
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
},
{
path: 'faq',
...[
'faq',
/////
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{
path: 'features',
})),
...[
'features',
/////
'fonctionnalites',
'funcionalidades',
'funzionalita',
'kenmerken'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule
)
},
})),
{
path: 'home',
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'markets',
...[
'markets',
/////
'maerkte',
'marches',
'markten',
'mercados',
'mercati'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule
)
})),
{
path: 'open',
loadChildren: () =>
import('./pages/open/open-page.module').then((m) => m.OpenPageModule)
},
{
path: 'p',
@ -180,27 +239,51 @@ const routes: Routes = [
(m) => m.PortfolioPageModule
)
},
{
path: 'pricing',
...[
'pricing',
/////
'precios',
'preise',
'prezzi',
'prijzen',
'prix'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule
)
},
{
path: 'register',
})),
...[
'register',
/////
'enregistrement',
'iscrizione',
'registratie',
'registrierung',
'registro'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule
)
},
{
path: 'resources',
})),
...[
'resources',
/////
'bronnen',
'recursos',
'ressourcen',
'ressources',
'risorse'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule
)
},
})),
{
path: 'start',
loadChildren: () =>

View File

@ -44,19 +44,130 @@
</main>
<footer
*ngIf="currentRoute === 'start'"
class="footer d-flex justify-content-center w-100"
*ngIf="
(currentRoute === 'blog' ||
currentRoute === 'faq' ||
currentRoute === 'features' ||
currentRoute === 'markets' ||
currentRoute === 'open' ||
currentRoute === 'pricing' ||
currentRoute === 'resources' ||
currentRoute === 'register' ||
currentRoute === 'start') &&
deviceType !== 'mobile'
"
class="d-flex justify-content-center py-4 w-100"
>
<div class="container text-center">
<div>
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
{{ version }}
<div class="container">
<div class="mb-3 row">
<div class="col-sm">
<a [routerLink]="['/']"><gf-logo /></a>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a>
</li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled">
<li><a i18n [routerLink]="['/about']">About</a></li>
<li *ngIf="hasPermissionForBlog">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li>
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
</li>
<li><a i18n [routerLink]="['/features']">Features</a></li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
</li>
<li>
<a i18n [routerLink]="['/about', 'license']">License</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a [routerLink]="['/open']">Open Startup</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/pricing']">Pricing</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</li>
<li *ngIf="hasPermissionForSubscription">
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
>Status</a
>
</li>
</ul>
</div>
<div class="col-sm">
<div class="h6 mt-2" i18n>Community</div>
<ul class="list-unstyled">
<li>
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>
</li>
<li>
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack</a
>
</li>
<li>
<a
href="https://twitter.com/ghostfolio_"
title="Follow Ghostfolio on Twitter"
>Twitter</a
>
</li>
<li>&nbsp;</li>
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li>
<li>
<a href="../en" title="Ghostfolio in English">English</a>
</li>
<li>
<a href="../es" title="Ghostfolio in Español">Español</a>
</li>
<li>
<a href="../fr" title="Ghostfolio en Français">Français</a>
</li>
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
</ul>
</div>
</div>
<div class="py-2 text-muted">
<small i18n
>The risk of loss in trading can be substantial. It is not advisable to
invest money you may need in the short term.</small
>
<div class="row text-center">
<div class="col">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
{{ version }}
</div>
</div>
<div class="row text-center text-muted">
<div class="col">
<small i18n
>The risk of loss in trading can be substantial. It is not advisable
to invest money you may need in the short term.</small
>
</div>
</div>
</div>
</footer>

View File

@ -4,6 +4,11 @@
display: block;
min-height: 100vh;
footer {
background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%;
}
main {
min-height: 100vh;
padding-top: 5rem;
@ -25,14 +30,13 @@
}
}
}
.footer {
height: 5rem;
line-height: 1;
}
}
:host-context(.is-dark-theme) {
footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
main {
.info-message-container {
.info-message {

View File

@ -32,6 +32,9 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasPermissionForBlog: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem;
public pageTitle: string;
public user: User;
@ -55,6 +58,22 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
this.info?.globalPermissions,
permissions.enableBlog
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
@ -64,8 +83,6 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
if (this.deviceType === 'mobile') {
setTimeout(() => {
const index = this.title.getTitle().indexOf('');

View File

@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -40,6 +41,7 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule,
BrowserModule,
GfHeaderModule,
GfLogoModule,
GfSubscriptionInterstitialDialogModule,
HttpClientModule,
MarkdownModule.forRoot(),

View File

@ -47,7 +47,7 @@
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteAccess(element.id)">
@ -57,6 +57,6 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

View File

@ -13,7 +13,9 @@ import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -28,6 +30,9 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: string;
public balance: number;
public currency: string;
public equity: number;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
@ -58,14 +63,33 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = translate(accountType);
this.name = name;
this.platformName = Platform?.name ?? '-';
this.valueInBaseCurrency = valueInBaseCurrency;
.subscribe(
({
accountType,
balance,
currency,
name,
Platform,
value,
valueInBaseCurrency
}) => {
this.accountType = translate(accountType);
this.balance = balance;
this.currency = currency;
this.changeDetectorRef.markForCheck();
});
if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber();
} else {
this.equity = null;
}
this.name = name;
this.platformName = Platform?.name ?? '-';
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
}
);
this.dataService
.fetchActivities({

View File

@ -12,14 +12,37 @@
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="user?.settings?.baseCurrency"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency"
></gf-value>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="currency"
[value]="balance"
>Cash Balance</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="currency"
[value]="equity"
>Equity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="accountType"
>Account Type</gf-value

View File

@ -207,6 +207,30 @@
</td>
</ng-container>
<ng-container matColumnDef="comment">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
mat-header-cell
></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<button
*ngIf="element.comment"
class="mx-1 no-min-width px-2"
mat-button
title="Note"
(click)="onOpenComment(element.comment); $event.stopPropagation()"
>
<ion-icon name="document-text-outline"></ion-icon>
</button>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -216,7 +240,7 @@
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)">

View File

@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'balance',
'value',
'currency',
'valueInBaseCurrency'
'valueInBaseCurrency',
'comment'
];
if (this.showActions) {
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
});
}
public onOpenComment(aComment: string) {
alert(aComment);
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}

View File

@ -108,7 +108,7 @@
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">

View File

@ -140,21 +140,9 @@
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Gather Historical Data</ng-container>
</button>
<button
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="element.activitiesCount !== 0"

View File

@ -10,13 +10,13 @@ import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
AdminMarketDataDetails,
EnhancedSymbolProfile,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { MarketData } from '@prisma/client';
import { MarketData, SymbolProfile } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -37,9 +37,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
symbolMapping: ''
});
public assetSubClass: string;
public benchmarks: Partial<SymbolProfile>[];
public countries: {
[code: string]: { name: string; value: number };
};
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
@ -51,11 +53,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder
) {}
public ngOnInit(): void {
this.benchmarks = this.dataService.fetchInfo().benchmarks;
this.initialize();
}
@ -72,6 +77,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetClass = translate(this.assetProfile?.assetClass);
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id;
});
this.marketDataDetails = marketData;
this.sectors = {};
@ -128,6 +136,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
}
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onSubmit() {
let symbolMapping = {};

View File

@ -37,6 +37,13 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="isBenchmark"
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
</mat-menu>
</div>

View File

@ -72,19 +72,6 @@
</div>
</div>
</div>
<div
*ngIf="info?.benchmarks?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Benchmarks</div>
<div class="w-50">
<table>
<tr *ngFor="let benchmark of info.benchmarks">
<td class="pl-1">{{ benchmark.symbol }}</td>
</tr>
</table>
</div>
</div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"

View File

@ -1,6 +1,17 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createPlatformDialog: true }"
[routerLink]="[]"
>
Add Platform
</a>
</div>
<table
class="gf-table w-100"
mat-table
@ -43,6 +54,20 @@
</td>
</ng-container>
<ng-container matColumnDef="accounts">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="accountCount"
>
<ng-container i18n>Accounts</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.accountCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
@ -57,7 +82,7 @@
[matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)">
@ -77,16 +102,4 @@
</table>
</div>
</div>
<div *ngIf="hasPermissionToCreatePlatform" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -1,4 +1,5 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
@ -13,8 +14,7 @@ import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Platform, Platform as PlatformModel } from '@prisma/client';
import { Platform } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@ -22,19 +22,18 @@ import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component';
@Component({
selector: 'gf-platform-overview',
styleUrls: ['./platform.component.scss'],
templateUrl: './platform.component.html'
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-platform',
styleUrls: ['./admin-platform.component.scss'],
templateUrl: './admin-platform.component.html'
})
export class AdminPlatformComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Platform> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'url', 'actions'];
public hasPermissionToCreatePlatform: boolean;
public hasPermissionToDeletePlatform: boolean;
public platforms: PlatformModel[];
public displayedColumns = ['name', 'url', 'accounts', 'actions'];
public platforms: Platform[];
private unsubscribeSubject = new Subject<void>();
@ -50,9 +49,9 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog'] && this.hasPermissionToCreatePlatform) {
if (params['createPlatformDialog']) {
this.openCreatePlatformDialog();
} else if (params['editDialog']) {
} else if (params['editPlatformDialog']) {
if (this.platforms) {
const platform = this.platforms.find(({ id }) => {
return id === params['platformId'];
@ -69,25 +68,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
const user = state.user;
this.hasPermissionToCreatePlatform = hasPermission(
user.permissions,
permissions.createPlatform
);
this.hasPermissionToDeletePlatform = hasPermission(
user.permissions,
permissions.deletePlatform
);
this.changeDetectorRef.markForCheck();
}
});
this.fetchPlatforms();
}
@ -101,9 +81,9 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
}
}
public onUpdatePlatform(aPlatform: PlatformModel) {
public onUpdatePlatform({ id }: Platform) {
this.router.navigate([], {
queryParams: { platformId: aPlatform.id, editDialog: true }
queryParams: { editPlatformDialog: true, platformId: id }
});
}

View File

@ -7,11 +7,12 @@ import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { AdminPlatformComponent } from './admin-platform.component';
import { GfCreateOrUpdatePlatformDialogModule } from './create-or-update-platform-dialog/create-or-update-platform-dialog.module';
import { AdminPlatformComponent } from './platform.component';
@NgModule({
declarations: [AdminPlatformComponent],
exports: [AdminPlatformComponent],
imports: [
CommonModule,
GfCreateOrUpdatePlatformDialogModule,

View File

@ -5,9 +5,9 @@ import { Subject } from 'rxjs';
import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-platform-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html'
})

View File

@ -0,0 +1,15 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="text-center" i18n>Platforms</h3>
<gf-admin-platform></gf-admin-platform>
</div>
</div>
<!--
<div class="row">
<div class="col">
<h3 class="text-center" i18n>Tags</h3>
</div>
</div>
-->
</div>

View File

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,27 @@
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { Subject } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-admin-settings',
styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html'
})
export class AdminSettingsComponent implements OnInit, OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({
declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminSettingsModule {}

View File

@ -109,7 +109,7 @@
[matMenuTriggerFor]="userMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #userMenu="matMenu" xPosition="before">
<button

View File

@ -1,6 +1,8 @@
<div class="mb-2 row">
<div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
>
<span i18n>Performance</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
@ -13,7 +15,6 @@
appearance="outline"
class="w-100 without-hint"
color="accent"
[hidden]="benchmarks?.length === 0"
>
<mat-label i18n>Compare with...</mat-label>
<mat-select
@ -28,6 +29,12 @@
[value]="symbolProfile.id"
>{{ symbolProfile.name }}</mat-option
>
<mat-option
*ngIf="hasPermissionToAccessAdminControl"
i18n
[routerLink]="['/admin', 'market-data']"
>Manage Benchmarks</mat-option
>
</mat-select>
</mat-form-field>
</div>

View File

@ -23,6 +23,7 @@ import {
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client';
import {
@ -59,6 +60,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean;
public constructor() {
Chart.register(
@ -76,6 +78,11 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
public ngOnChanges() {
this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions,
permissions.accessAdminControl
);
if (this.performanceDataItems) {
this.initialize();
}

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -16,7 +17,8 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
GfPremiumIndicatorModule,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
ReactiveFormsModule,
RouterModule
]
})
export class GfBenchmarkComparatorModule {}

View File

@ -8,216 +8,238 @@
<gf-logo [label]="pageTitle"></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']"
>Admin Control</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
<button
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
>
<ion-icon
class="d-none d-sm-block"
name="person-circle-outline"
size="large"
></ion-icon>
<ion-icon
class="d-block d-sm-none"
size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
<ul class="alig-items-center d-flex list-inline m-0">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen',
'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</button>
<hr class="m-0" />
</ng-container>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[routerLink]="['/account']"
>My Ghostfolio</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']"
>Accounts</a
>
</li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']"
>Admin Control</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
</li>
<li
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
class="list-inline-item"
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
<hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
</li>
<li class="list-inline-item">
<button
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
>
<ion-icon
class="d-none d-sm-block"
name="person-circle-outline"
size="large"
></ion-icon>
<ion-icon
class="d-block d-sm-none"
size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
[name]="
impersonationId
? 'radio-button-off-outline'
: 'radio-button-on-outline'
"
></ion-icon>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
<ion-icon
class="mr-2"
name="square-outline"
[name]="
accessItem.id === impersonationId
? 'radio-button-on-outline'
: 'radio-button-off-outline'
"
></ion-icon>
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</button>
<hr class="m-0" />
</ng-container>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio'
}"
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[routerLink]="['/account']"
>My Ghostfolio</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
<hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu>
</li>
</ul>
</ng-container>
<ng-container *ngIf="user === null">
<a
@ -231,67 +253,86 @@
></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'features',
'text-decoration-underline': currentRoute === 'features'
}"
[routerLink]="['/features']"
>Features</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
*ngIf="hasPermissionToAccessFearAndGreedIndex"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'markets',
'text-decoration-underline': currentRoute === 'markets'
}"
[routerLink]="['/markets']"
>Markets</a
>
<a
class="d-none d-sm-block no-min-width"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
<a
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="d-none d-sm-block"
color="primary"
mat-flat-button
[routerLink]="['/register']"
><ng-container i18n>Get started</ng-container>
</a>
<ul class="alig-items-center d-flex list-inline m-0">
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'features',
'text-decoration-underline': currentRoute === 'features'
}"
[routerLink]="['/features']"
>Features</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
</li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
</li>
<li
*ngIf="hasPermissionToAccessFearAndGreedIndex"
class="list-inline-item"
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'markets',
'text-decoration-underline': currentRoute === 'markets'
}"
[routerLink]="['/markets']"
>Markets</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block no-min-width p-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
><ion-icon name="logo-github"></ion-icon
></a>
</li>
<li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container>
</button>
</li>
<li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item"
>
<a
class="d-none d-sm-block"
color="primary"
mat-flat-button
[routerLink]="['/register']"
><ng-container i18n>Get started</ng-container>
</a>
</li>
</ul>
</ng-container>
</mat-toolbar>

View File

@ -1,12 +0,0 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.fab-container {
position: fixed;
right: 2rem;
bottom: 4rem;
z-index: 999;
}
}

View File

@ -1,31 +1,33 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Time in Market</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Time in Market</div>
<div class="justify-content-end">
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Buy</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalBuy"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Sell</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.totalSell"
></gf-value>
</div>
@ -33,31 +35,33 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Investment</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Investment</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.committedFunds"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Gross Performance</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Absolute Gross Performance</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Gross Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Gross Performance</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -70,8 +74,8 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{transaction} other {transactions}}
</div>
@ -79,8 +83,9 @@
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
</div>
@ -88,20 +93,21 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Net Performance</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Absolute Net Performance</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Net Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3" i18n>Net Performance</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -115,31 +121,33 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Total Assets</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Total Assets</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Valuables</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Valuables</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.items"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Emergency Fund</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Emergency Fund</div>
<div
class="align-items-center d-flex justify-content-end"
[ngClass]="{ 'cursor-pointer': hasPermissionToUpdateUserSettings }"
@ -152,30 +160,33 @@
></ion-icon>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buying Power</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Buying Power</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.cash"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Excluded from Analysis</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Excluded from Analysis</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
></gf-value>
</div>
@ -183,20 +194,23 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 font-weight-bold" i18n>Net Worth</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Annualized Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>
Annualized Performance
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -210,13 +224,14 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Dividend</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend"
></gf-value>
</div>

View File

@ -12,8 +12,9 @@
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="data.baseCurrency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="value"
></gf-value>
</div>
@ -40,8 +41,9 @@
i18n
size="medium"
[colorizeSign]="true"
[currency]="data.baseCurrency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
>
@ -61,8 +63,9 @@
<gf-value
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="averagePrice"
>Average Unit Price</gf-value
>
@ -71,8 +74,9 @@
<gf-value
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="SymbolProfile?.currency"
[value]="marketPrice"
>Market Price</gf-value
>
@ -81,9 +85,10 @@
<gf-value
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[isCurrency]="true"
[locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="minPrice"
>Minimum Price</gf-value
>
@ -92,9 +97,10 @@
<gf-value
i18n
size="medium"
[currency]="SymbolProfile?.currency"
[isCurrency]="true"
[locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
[unit]="SymbolProfile?.currency"
[value]="maxPrice"
>Maximum Price</gf-value
>
@ -113,8 +119,9 @@
<gf-value
i18n
size="medium"
[currency]="data.baseCurrency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="investment"
>Investment</gf-value
>
@ -123,8 +130,9 @@
<gf-value
i18n
size="medium"
[currency]="data.baseCurrency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
@ -133,8 +141,9 @@
<gf-value
i18n
size="medium"
[currency]="data.baseCurrency"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="feeInBaseCurrency"
>Fees</gf-value
>

View File

@ -45,8 +45,9 @@
<gf-value
class="mr-3"
[colorizeSign]="true"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformance"
></gf-value>
<gf-value

View File

@ -1,44 +1,55 @@
<h1 class="align-items-center d-flex" mat-dialog-title>
<span>Ghostfolio Premium</span>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
</h1>
<div class="flex-grow-1" mat-dialog-content>
<p class="h5" i18n>
Are you an ambitious investor who needs the full picture?
</p>
<p i18n>
By upgrading to Ghostfolio Premium, you will get these additional features:
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Allocations</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Performance Benchmarks</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Professional Data Provider</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>and more Features...</span>
</li>
</ul>
<p>Refine your personal investment strategy now.</p>
<div class="flex-grow-1 pt-0" mat-dialog-content>
<div class="intro-container">
<div class="intro-inner-container mx-auto">
<div class="h-100 intro w-100"></div>
</div>
</div>
<div>
<h5 class="align-items-center d-flex justify-content-center mb-3">
<span>Ghostfolio Premium</span>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
</h5>
<div class="font-weight-normal h5 mb-3 text-center" i18n>
Are you an ambitious investor who needs the full picture?
</div>
<p i18n>
Upgrade to Ghostfolio Premium today and gain access to exclusive features
to enhance your investment experience:
</p>
<ul class="list-unstyled mb-3 pl-3">
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Allocations</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Performance Benchmarks</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Professional Data Provider</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>and more Features...</span>
</li>
</ul>
<p class="m-0" i18n>
Get the tools to effectively manage your finances and refine your personal
investment strategy.
</p>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="closeDialog()">Skip</button>

View File

@ -5,7 +5,21 @@
max-height: unset;
ion-icon[name='checkmark-circle-outline'] {
color: rgba(var(--palette-accent-500), 1);
color: rgba(var(--palette-primary-500), 1);
}
.intro-container {
.intro-inner-container {
aspect-ratio: 16 / 9;
max-height: 18rem;
.intro {
background-image: url('/assets/intro.jpg');
background-position: top left;
background-repeat: no-repeat;
background-size: contain;
}
}
}
}
}

View File

@ -1,10 +1,11 @@
<mat-radio-group
class="text-nowrap"
class="d-block text-nowrap"
[formControl]="option"
(change)="onValueChange()"
>
<mat-radio-button
*ngFor="let option of options"
class="d-inline-flex"
[disabled]="isLoading"
[ngClass]="{ 'cursor-pointer': !isLoading }"
[value]="option.value"

View File

@ -24,6 +24,7 @@ export class AuthGuard implements CanActivate {
'/faq',
'/features',
'/markets',
'/open',
'/p',
'/pricing',
'/register',

View File

@ -7,6 +7,44 @@ import { AboutPageComponent } from './about-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
path: '',
loadChildren: () =>
import('./overview/about-overview-page.module').then(
(m) => m.AboutOverviewPageModule
)
},
{
path: 'changelog',
loadChildren: () =>
import('./changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
...[
'license',
/////
'licence',
'licencia',
'licentie',
'lizenz',
'licenza'
].map((path) => ({
path,
loadChildren: () =>
import('./license/license-page.module').then(
(m) => m.LicensePageModule
)
})),
{
path: 'privacy-policy',
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
}
],
component: AboutPageComponent,
path: '',
title: $localize`About`

View File

@ -1,28 +1,31 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
@Component({
host: { class: 'page' },
selector: 'gf-about-page',
styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
@HostBinding('class.with-info-message') get getHasMessage() {
return this.hasMessage;
}
public hasMessage: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public statistics: Statistics;
public tabs: TabConfiguration[] = [];
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
@ -31,38 +34,55 @@ export class AboutPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
globalPermissions,
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
const { globalPermissions, systemMessage } = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.statistics = statistics;
}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.tabs = [
{
iconName: 'reader-outline',
label: $localize`About`,
path: ['/about']
},
{
iconName: 'sparkles-outline',
label: $localize`Changelog`,
path: ['/about', 'changelog']
},
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/about', 'license']
},
{
iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`,
path: ['/about', 'privacy-policy'],
showCondition: this.hasPermissionForSubscription
}
];
this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,230 +1,21 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
<div class="about-container">
<p>
Ghostfolio is a lightweight wealth management application for
individuals to keep track of stocks, ETFs or cryptocurrencies and make
solid, data-driven investment decisions. The source code is fully
available as open source software (OSS). The project has been
initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://ghostfolio.slack.com"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
></ng-container
>
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
*ngIf="user?.subscription?.type === 'Premium'"
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
href="https://ghostfolio.slack.com"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<a
href="https://www.buymeacoffee.com/ghostfolio"
target="_blank"
title="Support Ghostfolio"
><img
class="mb-2"
src="../assets/images/button-buy-me-a-coffee.png"
width="180"
/></a>
</div>
</div>
</div>
</div>
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
<mat-card appearance="outlined">
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 24 hours)"
[value]="statistics?.activeUsers1d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.newUsers30d ?? '-'"
>New Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.activeUsers30d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a class="d-block" href="https://ghostfolio.slack.com">
<gf-value
size="large"
[value]="statistics?.slackCommunityUsers ?? '-'"
>Users in Slack community</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<gf-value
size="large"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<gf-value
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
>Stars on GitHub</gf-value
>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel">
<ng-container *ngFor="let tab of tabs">
<a
#rla="routerLinkActive"
*ngIf="tab.showCondition !== false"
class="px-3"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'changelog']"
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"
>Blog</a
>
</div>
</div>
</div>
<ion-icon size="large" [name]="tab.iconName"></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>
</nav>

View File

@ -1,21 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component';
@NgModule({
declarations: [AboutPageComponent],
imports: [
AboutPageRoutingModule,
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule
],
imports: [CommonModule, MatTabsModule, AboutPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AboutPageModule {}

View File

@ -1,36 +1,35 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
color: rgb(var(--dark-primary-text));
display: block;
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
.about-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
::ng-deep {
gf-about-page,
gf-changelog-page,
gf-privacy-policy-page {
flex: 1 1 auto;
overflow-y: auto;
}
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-dark.svg');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 2rem;
opacity: 0.87;
width: 10rem;
.mat-mdc-tab-link-container {
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
.about-container {
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg');
opacity: 1;
}
}
}

View File

@ -9,7 +9,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: $localize`Changelog & License`
title: $localize`Changelog`
}
];

View File

@ -1,23 +1,10 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card appearance="outlined" class="changelog">
<mat-card-content>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card appearance="outlined">
<mat-card-content>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1>
<div class="changelog">
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown';
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
@ -11,8 +10,7 @@ import { ChangelogPageComponent } from './changelog-page.component';
imports: [
ChangelogPageRoutingModule,
CommonModule,
MarkdownModule.forChild(),
MatCardModule
MarkdownModule.forChild()
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

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