Compare commits

...

79 Commits

Author SHA1 Message Date
52f0fb5ab8 Release 1.180.1 (#1170) 2022-08-19 18:24:41 +02:00
20195b2b1a Release 1.180.0 (#1169) 2022-08-18 21:15:17 +02:00
7fa4e6ebd2 Feature/resolve feature graphic of blog post (#1168)
* Resolve feature graphic of blog post

* Update changelog
2022-08-18 21:13:39 +02:00
d8531ddfcb Bugfix/fix links to blog posts (#1167)
* Fix links

* Update changelog
2022-08-18 21:11:10 +02:00
70d670b711 Bugfix/fix license (#1160)
* Fix license

* Update changelog
2022-08-17 23:23:39 +02:00
27b0663a80 Add translations (#1159) 2022-08-16 22:16:15 +02:00
874dfb0235 Improve links (#1158) 2022-08-16 21:52:37 +02:00
072db0d558 Add translations (#1157) 2022-08-16 21:40:51 +02:00
12e692429a Regenerate xlf (#1156) 2022-08-16 21:30:12 +02:00
e22b8b78b8 Feature/tag route titles with template literal strings (#1155)
* Tagged template literal strings

* Update changelog
2022-08-16 21:03:05 +02:00
dc5052f7dc Feature/set up language localization for german (#1153)
* Set up language localization for German

* Update changelog
2022-08-16 20:58:08 +02:00
335553e891 Feature/tag template literal strings (#1152)
* Tagged template literal strings

* Update changelog
2022-08-16 20:53:14 +02:00
d480ad1023 Extract locales (#1151) 2022-08-15 19:56:42 +02:00
7320751056 Feature/set up ng extract i18n merge (#1149)
* Set up ng-extract-i18n-merge

* Update changelog
2022-08-15 19:52:43 +02:00
108c0c13c4 Release 1.179.5 (#1150) 2022-08-15 18:17:57 +02:00
053a5cc5b5 Release 1.179.4 (#1148) 2022-08-14 10:10:54 +02:00
c456a8bcfe Release/1.179.3 (#1147)
* Clean up

* Release 1.179.3
2022-08-13 20:33:43 +02:00
6fcecb5bc6 Release 1.179.2 (#1145) 2022-08-13 13:39:37 +02:00
e4e0a7d9f0 Release 1.179.1 (#1144) 2022-08-13 12:16:39 +02:00
c7173761a3 Release 1.179.0 (#1143) 2022-08-13 10:44:38 +02:00
185e130d9f Feature/add blog post 500 stars on GitHub (#1138)
* Add blog post

* Update changelog
2022-08-13 10:42:56 +02:00
81245635af Feature/setup i18n (#1139)
* Setup i18n

* Update changelog
2022-08-13 10:29:36 +02:00
55182ac1af Feature/reduce maximum width of performance chart (#1137)
* Reduce maximum width

* Update changelog
2022-08-10 17:26:34 +02:00
0b446a30ae Release 1.178.0 (#1136) 2022-08-09 21:28:02 +02:00
c5e6602102 Improve filter by asset class (#1135) 2022-08-09 21:25:07 +02:00
573038f407 Feature/add default values for countries and sectors (#1133)
* Add default values

* Add database validation script

* Update changelog
2022-08-09 19:31:13 +02:00
dbc38e705e Feature/add url to symbol profile overrides (#1132)
* Add url to symbol profile overrides

* Improve filter by asset class

* Update changelog
2022-08-09 19:29:26 +02:00
f127e7c61a Feature/improve styling of benchmarks (#1131)
* Harmonize benchmark table styling

* Update changelog
2022-08-09 19:28:13 +02:00
4ccabde251 Feature/simplify exchange rate service initialization (#1128)
* Simplify initialization

* Update changelog
2022-08-08 19:25:38 +02:00
86ae88f90f Release 1.177.0 (#1127) 2022-08-04 13:38:25 +02:00
69bc1d67e1 Bugfix/fix database connection error handling (#1125)
* Fix database connection error handling

* Update changelog
2022-08-04 13:36:32 +02:00
03942aecda Feature/upgrade to prisma 4.1.1 (#1126)
* Upgrade prisma to 4.1.1

* Update changelog
2022-08-04 13:35:58 +02:00
7ec9170c0d baseline prisma bdd at first setup (#1124)
* Baseline database (migrations) in setup

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2022-08-04 13:34:32 +02:00
51431a7fb2 Feature/add ghostfolio as default to data sources (#1122)
* Add GHOSTFOLIO

* Update changelog
2022-08-03 21:36:12 +02:00
4adda6783d Feature/add agplv3 logo to landing page (#1121)
* Add AGPLv3 logo

* Update changelog
2022-08-02 22:14:27 +02:00
d5cd4c0dea Feature/upgrade nx to version 14.5.1 (#1120)
* Upgrade Nx including angular and nestjs

* Update changelog
2022-08-01 19:56:44 +02:00
34be10d755 Release 1.176.2 (#1119) 2022-07-31 08:16:30 +02:00
51f586e160 Feature/upgrade node from 14 to 16 (#1118)
* Upgrade to Node.js 16

* Update changelog
2022-07-31 08:14:52 +02:00
ff64a00196 Release 1.176.1 (#1117) 2022-07-31 00:19:40 +02:00
148f6f8762 Feature/refactor env variables access (#1116)
* Refactor env variables access

* Update changelog
2022-07-31 00:18:09 +02:00
bf2c4d1e9e Release 1.176.0 (#1115) 2022-07-30 21:27:35 +02:00
eee1f1c722 Feature/add page titles (#1114)
* Add page titles

* Update changelog
2022-07-30 21:26:08 +02:00
9f2a49a1c7 Feature/introduce max number of symbols per data provider request (#1111)
* Introduce maximum number of symbols per request

* Set log level settings

* Update changelog
2022-07-30 15:48:45 +02:00
44058b2d7a Add descriptions (#1113) 2022-07-30 15:46:58 +02:00
23634f3404 Add environment variables (#1112) 2022-07-30 15:21:14 +02:00
f93dab6086 Add FAQ (#1108) 2022-07-29 19:25:41 +02:00
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
181 changed files with 11818 additions and 2685 deletions

3
.gitignore vendored
View File

@ -24,15 +24,16 @@
# misc
/.angular/cache
.env.prod
/.sass-cache
/connect.lock
/coverage
/dist
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store

View File

@ -2,7 +2,7 @@ language: node_js
git:
depth: false
node_js:
- 14
- 16
services:
- docker

View File

@ -5,6 +5,185 @@ 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.180.1 - 18.08.2022
### Added
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
- Set up language localization for German (`de`)
- Resolved the feature graphic of the blog post
### Changed
- Tagged template literal strings in components for localization with `$localize`
### Fixed
- Fixed the license component in the about page
- Fixed the links to the blog posts
## 1.179.5 - 15.08.2022
### Added
- Set up i18n support
- Added a blog post: _500 Stars on GitHub_
### Changed
- Reduced the maximum width of the performance chart on the home page
## 1.178.0 - 09.08.2022
### Added
- Added `url` to the symbol profile overrides model for manual adjustments
- Added default values for `countries` and `sectors` of the symbol profile overrides model
### Changed
- Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.177.0 - 04.08.2022
### Added
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
- Added the `AGPLv3` logo to the landing page
### Changed
- Refactored the initialization of the exchange rate service
- Upgraded `angular` from version `14.0.2` to `14.1.0`
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
### Fixed
- Handled database connection errors (do not exit process)
## 1.176.2 - 31.07.2022
### Added
- Added page titles
### Changed
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
- Changed the log level settings
- Refactored the access of the environment variables in the bootstrap function (api)
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
### Todo
- Upgrade to `Node.js` 16+
## 1.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Support a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added

View File

@ -1,4 +1,4 @@
FROM node:14-alpine as builder
FROM node:16-alpine as builder
# Build application and add additional files
@ -45,7 +45,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
FROM node:16-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
### Run with Docker Compose
#### Prerequisites
@ -145,7 +162,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 14+)
- [Node.js](https://nodejs.org/en/download) (version 16+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)

View File

@ -77,41 +77,45 @@
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
"apps/client/src/assets",
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./.well-known"
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./assets"
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./assets"
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./"
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons"
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./"
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
@ -124,6 +128,10 @@
"namedChunks": true
},
"configurations": {
"development-en": {
"baseHref": "/en/",
"localize": ["en"]
},
"production": {
"fileReplacements": [
{
@ -162,15 +170,21 @@
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"development-en": {
"browserTarget": "client:build:development-en"
},
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
"browserTarget": "client:build"
"browserTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": ["messages.de.xlf"]
}
},
"lint": {
@ -188,6 +202,15 @@
"outputs": ["coverage/apps/client"]
}
},
"i18n": {
"locales": {
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
},
"client-e2e": {

View File

@ -1,6 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -24,7 +23,6 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -174,7 +172,6 @@ export class AdminService {
_count: {
select: { Account: true, Order: true }
},
alias: true,
Analytics: {
select: {
activityCount: true,
@ -194,7 +191,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
@ -206,7 +203,6 @@ export class AdminService {
: undefined;
return {
alias,
createdAt,
engagement,
id,

View File

@ -1,6 +1,17 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {
public constructor() {}
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {
this.initialize();
}
private async initialize() {
try {
await this.exchangeRateDataService.initialize();
} catch {}
}
}

View File

@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
@ -23,6 +23,7 @@ import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
@ -82,4 +83,10 @@ import { UserModule } from './user/user.module';
controllers: [AppController],
providers: [CronService]
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -1,5 +1,7 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
Body,
Controller,
@ -31,7 +33,9 @@ export class AuthController {
) {}
@Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
@ -59,9 +63,34 @@ export class AuthController {
const jwt: string = req.user.jwt;
if (jwt) {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}

View File

@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,7 +14,7 @@ export class AuthService {
private readonly userService: UserService
) {}
public async validateAnonymousLogin(accessToken: string) {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +27,7 @@ export class AuthService {
});
if (user) {
const jwt: string = this.jwtService.sign({
const jwt = this.jwtService.sign({
id: user.id
});
@ -40,6 +41,33 @@ export class AuthService {
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({
provider,
thirdPartyId
@ -57,13 +85,14 @@ export class AuthService {
});
}
const jwt: string = this.jwtService.sign({
return this.jwtService.sign({
id: user.id
});
return jwt;
} catch (err) {
throw new InternalServerErrorException('validateOAuthLogin', err.message);
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
);
}
}
}

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map(
({
accountId,
comment,
date,
fee,
id,
@ -50,6 +52,7 @@ export class ExportService {
}) => {
return {
accountId,
comment,
fee,
id,
quantity,

View File

@ -0,0 +1,81 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
public indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
public constructor(
private readonly configurationService: ConfigurationService
) {}
public use(req: Request, res: Response, next: NextFunction) {
let featureGraphicPath = 'assets/cover.png';
if (
req.path === '/en/blog/2022/08/500-stars-on-github' ||
req.path === '/en/blog/2022/08/500-stars-on-github/'
) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
}
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
// Skip
next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
res.send(
this.interpolate(this.indexHtmlDe, {
featureGraphicPath,
languageCode: 'de',
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
res.send(
this.interpolate(this.indexHtmlEn, {
featureGraphicPath,
languageCode: DEFAULT_LANGUAGE_CODE,
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -34,8 +34,20 @@ export class ImportController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try {
return await this.importService.import({
maxActivitiesToImport,
activities: importData.activities,
userId: this.request.user.id
});

View File

@ -17,9 +17,11 @@ export class ImportService {
public async import({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}): Promise<void> {
for (const activity of activities) {
@ -32,7 +34,11 @@ export class ImportService {
}
}
await this.validateActivities({ activities, userId });
await this.validateActivities({
activities,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
@ -42,6 +48,7 @@ export class ImportService {
for (const {
accountId,
comment,
currency,
dataSource,
date,
@ -52,6 +59,7 @@ export class ImportService {
unitPrice
} of activities) {
await this.orderService.createOrder({
comment,
fee,
quantity,
type,
@ -81,19 +89,15 @@ export class ImportService {
private async validateActivities({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
if (activities?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const existingActivities = await this.orderService.orders({

View File

@ -1,30 +1,46 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto {
@IsString()
@IsOptional()
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()
@ -39,6 +55,10 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true })
type: Type;

View File

@ -16,6 +16,7 @@ import {
DataSource,
Order,
Prisma,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
@ -71,6 +72,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
userId: string;
}
): Promise<Order> {
@ -80,6 +82,8 @@ export class OrderService {
return account.isDefault === true;
});
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
@ -139,9 +143,15 @@ export class OrderService {
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -150,7 +160,12 @@ export class OrderService {
data: {
...orderData,
Account,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}
});
}
@ -215,9 +230,10 @@ export class OrderService {
})
},
{
SymbolProfileOverrides: {
is: null
}
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
]
}
]
},
@ -298,6 +314,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
};
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
@ -305,6 +322,12 @@ export class OrderService {
delete data.Account;
}
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;
if (data.type === 'ITEM') {
@ -331,11 +354,17 @@ export class OrderService {
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
return this.prismaService.order.update({
data: {
...data,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
},
where
});

View File

@ -1,11 +1,20 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto {
@IsOptional()
@ -20,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@ -41,6 +57,10 @@ export class UpdateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString()
type: Type;

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
});
});
});

View File

@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('273.2')
});
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
});
});
});

View File

@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('75.80')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
});
});
});

View File

@ -14,8 +14,11 @@ import {
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
max,
min
min,
set
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
@ -323,6 +326,53 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
}
return investments;
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string

View File

@ -20,7 +20,12 @@ import {
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -217,7 +222,8 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
@Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -229,9 +235,16 @@ export class PortfolioController {
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if (
impersonationId ||

View File

@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type {
AccountWithValue,
DateRange,
GroupBy,
Market,
OrderWithAccount,
RequestWithUser
@ -64,6 +65,7 @@ import {
max,
parse,
parseISO,
set,
setDayOfYear,
startOfDay,
subDays,
@ -183,7 +185,8 @@ export class PortfolioService {
}
public async getInvestments(
aImpersonationId: string
aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -204,21 +207,49 @@ export class PortfolioService {
return [];
}
const investments = portfolioCalculator.getInvestments().map((item) => {
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of current month
const dateOfCurrentMonth = format(
set(new Date(), { date: 1 }),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
});
if (investmentOfCurrentMonth.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
const investmentOfToday = investments.filter(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
@ -227,6 +258,7 @@ export class PortfolioService {
investment: lastInvestment?.investment ?? 0
});
}
}
return sortBy(investments, (investment) => {
return investment.date;

View File

@ -1,6 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -93,7 +96,11 @@ export class SubscriptionController {
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
res.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
);
}
@Post('stripe/checkout-session')

View File

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
@ -33,7 +34,9 @@ export class SubscriptionService {
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId,
line_items: [
{

View File

@ -36,14 +36,7 @@ export class UserService {
}
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
@ -63,7 +56,6 @@ export class UserService {
}
return {
alias,
id,
permissions,
subscription,

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
{
"LUNA1": "Terra",
"UNI1": "Uniswap"
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -1,11 +1,24 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, {
logger:
NODE_ENV === 'production'
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',
@ -20,11 +33,11 @@ async function bootstrap() {
})
);
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 3333;
await app.listen(port, host, () => {
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${host}:${port}`);
Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log('');
});
}

View File

@ -15,7 +15,9 @@ export class ConfigurationService {
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
@ -33,8 +35,8 @@ export class ConfigurationService {
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),

View File

@ -168,6 +168,7 @@ export class DataProviderService {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
@ -176,25 +177,59 @@ export class DataProviderService {
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const startTimeDataSource = performance.now();
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
response[symbol] = dataProviderResponse;
}
Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`
);
})
);
}
}
await Promise.all(promises);
Logger.debug('------------------------------------------------');
Logger.debug(
`Fetched ${items.length} quotes in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`
);
Logger.debug('================================================');
return response;
}

View File

@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
// It is not recommended using more than 15-20 tickers per request
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
return 20;
}
public getName(): DataSource {
return DataSource.EOD_HISTORICAL_DATA;
}

View File

@ -20,6 +20,8 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; // TODO: Return only one symbol
getMaxNumberOfSymbolsPerRequest?(): number;
getName(): DataSource;
getQuotes(

View File

@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
@ -203,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.YAHOO;
}
@ -261,6 +270,16 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');

View File

@ -22,9 +22,7 @@ export class ExchangeRateDataService {
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
this.initialize();
}
) {}
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
@ -122,15 +120,6 @@ export class ExchangeRateDataService {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1;
if (aFromCurrency !== aToCurrency) {

View File

@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string;

View File

@ -1,15 +1,25 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
implements OnModuleInit, OnModuleDestroy
{
public async onModuleInit() {
try {
await this.$connect();
} catch (error) {
Logger.error(error, 'PrismaService');
}
}
async onModuleDestroy() {
public async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -115,9 +115,16 @@ export class SymbolProfileService {
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = item.SymbolProfileOverrides
.sectors as unknown as Sector[];
}
item.url = item.SymbolProfileOverrides?.url ?? item.url;
delete item.SymbolProfileOverrides;
}

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { ModulePreloadService } from './core/module-preload.service';
@ -53,30 +54,56 @@ const routes: Routes = [
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{
path: 'de/blog/2021/07/hallo-ghostfolio',
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
},
{
path: 'en/blog/2021/07/hello-ghostfolio',
path: 'faq',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{
path: 'features',
@ -215,7 +242,10 @@ const routes: Routes = [
}
)
],
providers: [ModulePreloadService],
providers: [
ModulePreloadService,
{ provide: TitleStrategy, useClass: PageTitleStrategy }
],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@ -21,8 +21,10 @@
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
>{{ baseUrl }}/p/{{ element.id }}</a
<a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
>
</ng-container>
</td>

View File

@ -8,6 +8,7 @@ import {
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces';
@Component({
@ -24,6 +25,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public displayedColumns = [];
public constructor() {}
@ -44,7 +46,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public onDeleteAccess(aId: string) {
const confirmation = confirm(
'Do you really want to revoke this granted access?'
$localize`Do you really want to revoke this granted access?`
);
if (confirmation) {

View File

@ -1,3 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -50,7 +50,6 @@
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>

View File

@ -69,7 +69,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
public onDeleteAccount(aId: string) {
const confirmation = confirm('Do you really want to delete this account?');
const confirmation = confirm(
$localize`Do you really want to delete this account?`
);
if (confirmation) {
this.accountDeleted.emit(aId);

View File

@ -103,7 +103,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onAddCurrency() {
const currency = prompt('Please add a currency:');
const currency = prompt($localize`Please add a currency:`);
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
@ -116,7 +116,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');
const confirmation = confirm(
$localize`Do you really want to delete this coupon?`
);
if (confirmation === true) {
const coupons = this.coupons.filter((coupon) => {
@ -127,7 +129,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
const confirmation = confirm(
$localize`Do you really want to delete this currency?`
);
if (confirmation === true) {
const currencies = this.customCurrencies.filter((currency) => {
@ -142,7 +146,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onFlushCache() {
const confirmation = confirm('Do you really want to flush the cache?');
const confirmation = confirm(
$localize`Do you really want to flush the cache?`
);
if (confirmation === true) {
this.cacheService
@ -190,7 +196,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onSetSystemMessage() {
const systemMessage = prompt('Please set your system message:');
const systemMessage = prompt($localize`Please set your system message:`);
if (systemMessage) {
this.putSystemMessage(systemMessage);

View File

@ -55,7 +55,9 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
public onDeleteUser(aId: string) {
const confirmation = confirm('Do you really want to delete this user?');
const confirmation = confirm(
$localize`Do you really want to delete this user?`
);
if (confirmation) {
this.dataService

View File

@ -29,11 +29,10 @@
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>{{ userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>{{ (userItem.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"

View File

@ -124,13 +124,11 @@
: 'radio-button-on-outline'
"
></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
disabled="false"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>

View File

@ -109,7 +109,7 @@ export class HeaderComponent implements OnChanges {
data: {
accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
title: 'Sign in'
title: $localize`Sign in`
},
width: '30rem'
});
@ -123,7 +123,7 @@ export class HeaderComponent implements OnChanges {
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
alert($localize`Oops! Incorrect Security Token.`);
return EMPTY;
}),

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -9,7 +10,6 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -27,7 +27,7 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;

View File

@ -34,6 +34,7 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-2 py-3"
[theme]="{
height: '1.5rem',
width: '100%'

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
@ -6,7 +7,6 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import {
PortfolioPerformance,
UniqueAsset,
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public errors: UniqueAsset[];
public hasError: boolean;

View File

@ -2,19 +2,8 @@
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div class="row w-100">
<div class="chart-container col">
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
@ -23,6 +12,20 @@
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>
<div class="overview-container row mt-1">

View File

@ -5,7 +5,8 @@
.chart-container {
aspect-ratio: 16 / 9;
max-height: 50vh;
height: auto;
max-width: 50rem;
// Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) {

View File

@ -1,3 +1,7 @@
:host {
display: block;
ngx-skeleton-loader {
height: 100%;
}
}

View File

@ -13,7 +13,7 @@ import {
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
@ -22,7 +22,10 @@ import {
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types';
import {
BarController,
BarElement,
Chart,
LineController,
LineElement,
@ -31,6 +34,7 @@ import {
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({
@ -42,9 +46,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;
@ -53,6 +59,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
annotationPlugin,
BarController,
BarElement,
LinearScale,
LineController,
LineElement,
@ -78,7 +87,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
this.isLoading = true;
if (this.investments?.length > 0) {
if (!this.groupBy && this.investments?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.investments[0];
this.investments.unshift({
@ -102,13 +111,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
const data = {
labels: this.investments.map((position) => {
return position.date;
labels: this.investments.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => {
return position.investment;
}),
@ -137,6 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
@ -150,6 +161,39 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: 'Savings Rate',
display: true,
font: { size: '10px', weight: 'normal' },
padding: {
x: 4,
y: 2
},
position: 'start'
},
scaleID: 'y',
type: 'line',
value: this.savingsRate
}
: undefined,
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
@ -164,6 +208,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},
@ -178,8 +223,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return transformTickToAbbreviation(value);
@ -192,13 +239,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
type: this.groupBy ? 'bar' : 'line'
});
}
}
this.isLoading = false;
}
}
}
private getTooltipPluginConfiguration() {
return {

View File

@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
@ -16,7 +19,10 @@ export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
private internetIdentityService: InternetIdentityService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {}
ngOnInit() {}
@ -31,4 +37,14 @@ export class LoginWithAccessTokenDialog {
public onClose() {
this.dialogRef.close();
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.dialogRef.close();
this.router.navigate(['/']);
} catch {}
}
}

View File

@ -5,16 +5,7 @@
></gf-dialog-header>
<div mat-dialog-content>
<div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center">
<a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a
>
</div>
<div class="my-3 text-center text-muted" i18n>or</div>
</ng-container>
<div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Security Token</mat-label>
<textarea
@ -24,6 +15,29 @@
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">
<button
class="mb-2"
mat-stroked-button
(click)="onLoginWithInternetIdentity()"
>
<img
class="mr-2"
src="../assets/icons/internet-computer.svg"
style="height: 0.75rem"
/><span i18n>Sign in with Internet Identity</span>
</button>
<a href="../api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="../assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Sign in with Google</span></a
>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions>

View File

@ -12,4 +12,12 @@
}
}
}
.mat-form-field {
::ng-deep {
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
}
}

View File

@ -45,7 +45,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() {
const emergencyFundInput = prompt(
'Please enter the amount of your emergency fund:',
$localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund.toString()
);
const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -142,4 +142,15 @@
</button>
</div>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

View File

@ -27,6 +27,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToShowValues = true;
@Input() locale: string;
@Input() pageSize = Number.MAX_SAFE_INTEGER;

View File

@ -17,6 +17,14 @@ import { ToggleOption } from '@ghostfolio/common/types';
styleUrls: ['./toggle.component.scss']
})
export class ToggleComponent implements OnChanges, OnInit {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ label: $localize`Today`, value: '1d' },
{ label: $localize`YTD`, value: 'ytd' },
{ label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' },
{ label: $localize`Max`, value: 'max' }
];
@Input() defaultValue: string;
@Input() isLoading: boolean;
@Input() options: ToggleOption[];

View File

@ -21,6 +21,7 @@ export class AuthGuard implements CanActivate {
'/de/blog',
'/demo',
'/en/blog',
'/faq',
'/features',
'/markets',
'/p',

View File

@ -56,14 +56,16 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (!this.snackBarRef) {
if (this.info.isReadOnlyMode) {
this.snackBarRef = this.snackBar.open(
'This feature is currently unavailable. Please try again later.',
$localize`This feature is currently unavailable. Please try again later.`,
undefined,
{ duration: 6000 }
);
} else {
this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.',
this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
$localize`This feature requires a subscription.`,
this.hasPermissionForSubscription
? $localize`Upgrade Plan`
: undefined,
{ duration: 6000 }
);
}
@ -79,8 +81,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
'Oops! Something went wrong. Please try again later.',
'Okay',
$localize`Oops! Something went wrong. Please try again later.`,
$localize`Okay`,
{ duration: 6000 }
);

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AboutPageComponent,
path: '',
title: $localize`About`
}
];
@NgModule({

View File

@ -173,8 +173,18 @@
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div
class="col-md-4 col-xs-12 my-2"
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
@ -186,7 +196,7 @@
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
@ -196,7 +206,7 @@
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ChangelogPageComponent } from './changelog-page.component';
const routes: Routes = [
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: $localize`Changelog & License`
}
];
@NgModule({

View File

@ -4,7 +4,7 @@
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
@ -15,7 +15,7 @@
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card-content>
<markdown [src]="'assets/LICENSE'"></markdown>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
const routes: Routes = [
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: PrivacyPolicyPageComponent,
path: '',
title: $localize`Privacy Policy`
}
];
@NgModule({

View File

@ -2,7 +2,7 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
<markdown [src]="'assets/privacy-policy.md'"></markdown>
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
</div>
</div>
</div>

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountPageComponent } from './account-page.component';
const routes: Routes = [
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountPageComponent,
path: '',
title: $localize`My Ghostfolio`
}
];
@NgModule({

View File

@ -218,7 +218,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}
public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:');
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
if (couponCode) {
@ -227,17 +227,21 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
{
duration: 3000
});
}
);
return EMPTY;
})
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(
'✅ Coupon code has been redeemed',
'Reload',
'✅' + $localize`Coupon code has been redeemed`,
$localize`Reload`,
{
duration: 3000
}
@ -283,7 +287,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.registerDevice();
} else {
const confirmation = confirm(
'Do you really want to remove this sign in method?'
$localize`Do you really want to remove this sign in method?`
);
if (confirmation) {

View File

@ -8,10 +8,6 @@
<div class="col">
<mat-card class="mb-3">
<mat-card-content>
<div *ngIf="user.alias" class="d-flex py-1">
<div class="pr-1 w-50" i18n>Alias</div>
<div class="pl-1 w-50">{{ user.alias }}</div>
</div>
<div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
class="d-flex py-1"

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountsPageComponent } from './accounts-page.component';
const routes: Routes = [
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountsPageComponent,
path: '',
title: $localize`Accounts`
}
];
@NgModule({

View File

@ -10,8 +10,6 @@ import { AdminPageComponent } from './admin-page.component';
const routes: Routes = [
{
path: '',
component: AdminPageComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
@ -19,7 +17,10 @@ const routes: Routes = [
{ path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent }
]
],
component: AdminPageComponent,
path: '',
title: $localize`Admin Control`
}
];

View File

@ -4,8 +4,8 @@ import { RouterModule, Routes } from '@angular/router';
import { AuthPageComponent } from './auth-page.component';
const routes: Routes = [
{ path: '', component: AuthPageComponent },
{ path: ':jwt', component: AuthPageComponent }
{ component: AuthPageComponent, path: '' },
{ component: AuthPageComponent, path: ':jwt' }
];
@NgModule({

View File

@ -28,6 +28,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'

View File

@ -6,9 +6,10 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: HalloGhostfolioPageComponent,
canActivate: [AuthGuard]
path: '',
title: 'Hallo Ghostfolio'
}
];

View File

@ -3,8 +3,8 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<h1 class="mb-1">Hallo Ghostfolio 👋</h1>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>
@ -68,7 +68,7 @@
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
src="../assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>

View File

@ -6,9 +6,10 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: HelloGhostfolioPageComponent,
canActivate: [AuthGuard]
path: '',
title: 'Hello Ghostfolio'
}
];

View File

@ -3,8 +3,8 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<h1 class="mb-1">Hello Ghostfolio 👋</h1>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>
@ -66,7 +66,7 @@
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
src="../assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>

View File

@ -6,9 +6,10 @@ import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-sou
const routes: Routes = [
{
path: '',
canActivate: [AuthGuard],
component: FirstMonthsInOpenSourcePageComponent,
canActivate: [AuthGuard]
path: '',
title: 'First months in Open Source'
}
];

View File

@ -3,11 +3,11 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>
<h1 class="mb-1">
👻 Ghostfolio
<span class="text-nowrap">First months in Open Source</span>
</h1>
<div class="text-muted"><small>05.01.2022</small></div>
<div class="text-muted"><small>2022-01-05</small></div>
</div>
<section class="mb-4">
<p>
@ -20,9 +20,7 @@
<h2 class="h4">From 1* to 100 stars on GitHub</h2>
<p>
When I decided to
<a [routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>publish</a
>
<a href="../en/blog/2021/07/hello-ghostfolio">publish</a>
the project as
<a href="https://github.com/ghostfolio/ghostfolio"
>open source software</a

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsInternetIdentityPageComponent,
path: '',
title: 'Ghostfolio meets Internet Identity'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsInternetIdentityRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-ghostfolio-meets-internet-identity-page',
styleUrls: ['./ghostfolio-meets-internet-identity-page.scss'],
templateUrl: './ghostfolio-meets-internet-identity-page.html'
})
export class GhostfolioMeetsInternetIdentityPageComponent {}

View File

@ -0,0 +1,183 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio meets Internet Identity</h1>
<div class="mb-3 text-muted"><small>2022-07-23</small></div>
<img
alt="Ghostfolio meets Internet Identity Teaser"
class="rounded w-100"
src="../assets/images/blog/ghostfolio-meets-internet-identity.png"
title="Ghostfolio meets Internet Identity"
/>
</div>
<section class="mb-4">
<p>
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, supports passwordless authentication as
of now thanks to the integration of
<a href="https://identity.ic0.app">Internet Identity</a>. This
blockchain authentication system enables you to sign in securely and
anonymously to Ghostfolio. With this latest update, Ghostfolio is
ready for Web3.
</p>
<div class="container my-4">
<div class="row">
<div class="col-md-10 offset-md-1">
<blockquote class="blockquote m-0">
<p class="mb-0">Track your portfolio without being tracked</p>
</blockquote>
</div>
</div>
</div>
<p>
To avoid the security issues that arise with password authentication
on the traditional web, the
<a href="https://internetcomputer.org">Internet Computer</a>
blockchain by <a href="https://dfinity.org">dfinity</a> has
introduced a new cryptographic authentication system. It is called
<i>Internet Identity</i> and is as convenient to use as Web2
<a href="https://en.wikipedia.org/wiki/OAuth">OAuth</a> ("Open
Authorization") providers like <i>Google Sign-In</i> or
<i>Facebook Login</i>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to use Internet Identity?</h2>
<p>
<i>Internet Identity</i> is based on the
<a href="https://en.wikipedia.org/wiki/WebAuthn"
>WebAuthn protocol</a
>
and uses secure cryptographic authentication. It provides three
options to authenticate yourself:
</p>
<ul>
<li>
The built-in biometric authentication methods of your smartphone
or laptop (fingerprint sensor, <i>Face ID</i>, <i>Touch ID</i>)
</li>
<li>The password or pin to unlock your computer or mobile phone</li>
<li>A security key plugged into the USB port of your computer</li>
</ul>
<p>
When you authenticate with <i>Internet Identity</i>, the service
only gets a dedicated pseudonym rather than sensitive user data like
the email address or phone number. This preserves your anonymity and
prevents you being tracked on the Internet.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The key benefits in a nutshell</h2>
<ul>
<li>
Authenticate yourself securely without the need of an email
address, username, or a password: all you need is your device to
log in.
</li>
<li>
Built-in recovery mechanisms to ensure you are not locked out of
any service that requires the <i>Internet Identity</i>.
</li>
<li>
Log in to various Internet services without being tracked by big
tech companies.
</li>
</ul>
</section>
<section class="mb-4">
<p>
If you would like to provide feedback or get involved in further
development of Ghostfolio, please get in touch by email via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Anonymity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Auth Provider</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Authentication</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Blockchain</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptography</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">dfinity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Face ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fingerprint</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Computer</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Identity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OAuth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Password</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">passwordless</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Security</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Touch ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">WebAuthn</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsInternetIdentityRoutingModule } from './ghostfolio-meets-internet-identity-page-routing.module';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
@NgModule({
declarations: [GhostfolioMeetsInternetIdentityPageComponent],
imports: [
CommonModule,
GhostfolioMeetsInternetIdentityRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsInternetIdentityPageModule {}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HowDoIGetMyFinancesInOrderPageComponent,
path: '',
title: 'How do I get my finances in order?'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HowDoIGetMyFinancesInOrderRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-how-do-i-get-my-finances-in-order-page',
styleUrls: ['./how-do-i-get-my-finances-in-order-page.scss'],
templateUrl: './how-do-i-get-my-finances-in-order-page.html'
})
export class HowDoIGetMyFinancesInOrderPageComponent {}

View File

@ -0,0 +1,209 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">How do I get my finances in order?</h1>
<div class="text-muted"><small>2022-07-14</small></div>
</div>
<section class="mb-4">
<p>
Before you can think of
<a [routerLink]="['/resources']">long-term investing</a>, you have
to get your finances in order. Take a look at Peter's journey to see
how you can achieve it, too.
</p>
<p>
Peter enjoys life, but sometimes he overspends a bit. He realizes it
when money runs out already in the middle of the month. Then the
next few days become difficult and saving money is out of the
question. That is why he wants to plan his monthly budget in the
future.
</p>
<p>
Peter has a decent salary in his job. But as soon as the salary
arrives in his account, it melts away. In order to find out where
his money is disappearing, he has decided to plan his monthly
budget. He wants to be able to put money aside for major expenses
and set financial goals.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Keeping a traditional or digital budget book</h2>
<p>
First, Peter obtains an overview of his personal finances. To do so,
he starts keeping a budget book. This can be done on paper by
listing his income and expenses for a few months, or he can create a
simple spreadsheet in Excel. In addition, many credit card providers
offer the feature within their apps of having expenses automatically
analyzed according to different categories. According to the
<a href="https://www.bfs.admin.ch/bfs/en/home.html"
>Swiss Federal Statistical Office</a
>, households in Switzerland spend around 20 percent of their
disposable income on housing and around 10 percent on groceries.
</p>
<p>
With the smartphone app, Peter has a better overview of his
financial affairs. The application assigns the bookings to
individual categories. Peter can assign specific budgets to each of
them. This way, he is always informed about how much money he can
still spend on restaurant visits in the current month, for example.
A traditional method is the so-called
<a
href="https://www.investopedia.com/envelope-budgeting-system-5208026"
>envelope method</a
>. One envelope is labeled for each category like groceries, rent or
student loans. The monthly budget is put into the envelopes in cash.
Many apps offer the same budgeting system in a more convenient,
virtual way.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Planning and investing</h2>
<p>
If Peter has spent less money than planned on eating out at
restaurants, he can set aside the remaining amount. This way, he can
treat himself to something special every now and then. From now on,
he saves a fixed amount of money in a separate account (“pay
yourself first”) by standing order at the beginning of the month. As
soon as there are three net monthly salaries in the account, he
invests the monthly savings amount in a passively managed global
equity fund. This grows his assets over the years and allows him to
supplement his pension later.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to achieve your financial goals?</h2>
<p>
If you follow these five actionable tips, you can reach your
financial goals easier and faster. Start with one tip and when you
implement it well, you can try the next one to ultimately have more
money at the end of the month.
</p>
<h3 class="h5">1. Visualize your goals</h3>
<p>
Start visualizing your goals. For example, hang up pictures of the
travel destination you are saving for. Imagine that you have already
achieved the goal to slowly adapt your mindset.
</p>
<h3 class="h5">2. Write off personal items</h3>
<p>
Do as a business does and write off purchases annually. For a new
car, you could set aside one-sixth of the purchase price each year.
</p>
<h3 class="h5">3. Save at the beginning of the month</h3>
<p>
Have a savings amount deducted from your account at the beginning of
the month. Then you will pay yourself first and spend less money.
</p>
<h3 class="h5">4. Follow the 50-30-20 rule</h3>
<p>
You need 50 percent of your disposable income for fixed costs. 30
percent can be spent on personal needs such as hobbies, travel or
consumer electronics. 20 percent is left for savings or to pay off
potential debts.
</p>
<h3 class="h5">5. Track your progress</h3>
<p>
If you have any money to spare, invest it in a broadly diversified,
low-cost portfolio excluding the risks of individual stocks. Track
the progress of your portfolio and net worth with
<a href="https://ghostfol.io">Ghostfolio</a>, a web-based personal
finance management software.
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Assets</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budget</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cash</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Debt</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Equity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Expense</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fund</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Goal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Guide</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Income</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Money</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Net Worth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Pension</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Salary</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Saving</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Spreadsheet</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HowDoIGetMyFinancesInOrderRoutingModule } from './how-do-i-get-my-finances-in-order-page-routing.module';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
@NgModule({
declarations: [HowDoIGetMyFinancesInOrderPageComponent],
imports: [
CommonModule,
HowDoIGetMyFinancesInOrderRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HowDoIGetMyFinancesInOrderPageModule {}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FiveHundredStarsOnGitHubPageComponent,
path: '',
title: '500 Stars on GitHub'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FiveHundredStarsOnGitHubRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-500-stars-on-github-page',
styleUrls: ['./500-stars-on-github-page.scss'],
templateUrl: './500-stars-on-github-page.html'
})
export class FiveHundredStarsOnGitHubPageComponent {}

View File

@ -0,0 +1,195 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">500 Stars</h1>
<div class="mb-3 text-muted"><small>2022-08-18</small></div>
<img
alt="500 Stars on GitHub Teaser"
class="rounded w-100"
src="../assets/images/blog/500-stars-on-github.jpg"
title="500 Stars on GitHub"
/>
</div>
<section class="mb-4">
<p>
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, is celebrating 500 stars on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
is a major milestone for this open source project and a good time
for another
<a href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
>recap</a
>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Growing Community</h2>
<p>
The Ghostfolio community is growing on various platforms and has
recently passed 100 members on
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
as well as 100 followers on
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have
not joined yet, this is a good time to make sure you do not miss out
on any future updates.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Message Queue: Asynchronous Processing</h2>
<p>
Overall
<a href="https://status.ghostfol.io">stability and robustness</a>
has increased significantly since the introduction of a
<a href="https://github.com/OptimalBits/bull">message queue</a>. The
workers of this robust queue system process jobs, namely gathering
historical market data, asynchronously in the background to not
bother the main service.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Ready for Web 3.0</h2>
<p>
The
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
>recent integration of Internet Identity</a
>, a blockchain authentication system, makes Ghostfolio ready for
Web3. This third iteration of the World Wide Web is the vision of a
new and better Internet based on decentralized blockchains to give
power back to the users. <i>Internet Identity</i> created by the
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
sign in securely and anonymously to Ghostfolio without an email
address, username, or a password. All you need is your device with
built-in biometric authentication.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Break-even Point</h2>
<p>
Despite the complicated
<a [routerLink]="['/markets']">economic situation</a> at this time,
the goal set at the beginning of the year to build a sustainable
business and reach break-even with the SaaS offering (<a
[routerLink]="['/markets']"
>Ghostfolio Premium</a
>) has been achieved. We will continue to leverage the revenue to
further improve the fully managed cloud offering for our paying
customers. A new goal we have set for ourselves is to become
profitable.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Outlook</h2>
<p>
Besides all the positive accomplishments during the last months,
there is still a lot of room for improvement. It would be great to
onboard more contributors who are actively involved in software
engineering to realize the full potential of open source software.
If you are a web developer and interested in personal finance,
please get in touch by email via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
happy to discuss ideas.
</p>
<p>
We would like to say thank you for all your feedback and support
since the beginning of this project.
</p>
<p>
Off to the next 500 stars!<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Blockchain</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">BuildInPublic</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cloud</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Future</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Goal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Identity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Message Queue</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OpenSaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Progress</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">User Feedback</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Worker</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

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 { FiveHundredStarsOnGitHubRoutingModule } from './500-stars-on-github-page-routing.module';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
@NgModule({
declarations: [FiveHundredStarsOnGitHubPageComponent],
imports: [CommonModule, FiveHundredStarsOnGitHubRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FiveHundredStarsOnGitHubPageModule {}

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