Compare commits

..

81 Commits

Author SHA1 Message Date
c69686651e Release 2.22.0 (#2638) 2023-11-11 18:59:43 +01:00
93b6011ddc Feature/refactor get range in market data service (#2631)
* Refactor to unique asset in getRange()

* Update changelog
2023-11-11 18:57:41 +01:00
f567e25f27 Feature/introduce action menus in overview of action control panel (#2637)
* Introduce action menus

* Exchange rates management
* Coupons management

* Update changelog
2023-11-11 18:48:23 +01:00
5dc538bafb Feature/optimize testimonial carousel style on mobile (#2634)
* Optimize style on mobile

* Update changelog
2023-11-11 17:29:59 +01:00
b4de06fcf0 Feature/add platform icons to account selectors (#2633)
* Add platform icons to account selectors

* Update changelog
2023-11-11 17:27:29 +01:00
27da0eb26e Feature/harmonize name column of historical market data table (#2632)
* Harmonize name column

* Update changelog
2023-11-11 09:02:12 +01:00
8ff80c10e5 Feature/extend personal finance tools pages 20231110 (#2630)
* Add Magnifi

* Add Basil Finance
2023-11-11 09:01:58 +01:00
5db5d5e79a Release 2.21.0 (#2629) 2023-11-09 19:25:14 +01:00
12aac101bd Feature/extend system message (#2628)
* Extend system message

* Update changelog
2023-11-09 19:23:36 +01:00
3a66ccdebe Bugfix/fix unit in overview of home page (#2626)
* Fix unit

* Update changelog
2023-11-09 19:22:15 +01:00
6a722d1bb7 Bugfix/fix get quotes in financial modeling prep service (#2627)
* Fix get quotes

* Update changelog
2023-11-09 18:03:09 +01:00
7c9407d5dc Release 2.20.0 (#2623) 2023-11-08 19:39:38 +01:00
8abb517ac6 Feature/remove loading indicator of unit in overview of home page (#2622)
* Remove loading indicator of unit

* Update changelog
2023-11-08 19:37:45 +01:00
dec1d89c5c Feature/increase timeout in data provider and enhancer health check endpoint (#2621)
* Increase timeout in health check endpoint (data enhancer and provider)

* Update changelog
2023-11-08 18:02:38 +01:00
24e9ecc3e2 Update locales (#2620) 2023-11-08 16:51:44 +01:00
4a1e05b8cd Improve historical market data import (#2581)
* Add form group for historical data import

* Update changelog
2023-11-07 18:16:00 +01:00
39d1a85267 Feature/extend personal finance tools pages 20231107 (#2618)
* Add Monarch Money

* Add YNAB

* Add Allvue Systems
2023-11-07 18:14:54 +01:00
7cb86de7af Feature/remove account type from account database model (#2616)
* Remove account type

* Update changelog
2023-11-07 17:56:18 +01:00
aa078588e8 Release 2.19.0 (#2615) 2023-11-06 18:02:15 +01:00
fcef0a72d5 Bugfix/improve handling of derived currencies (#2604)
* Improve handling of derived currencies

* Update changelog
2023-11-06 17:58:39 +01:00
29987d3e2f Add missing activity types (#2601)
* FEE
* INTEREST
* LIABILITY
2023-11-06 13:09:14 +01:00
6284b4dfe8 Feature/improve localization of fear and greed index (#2612)
* Improve localization

* Update changelog
2023-11-06 13:07:53 +01:00
00342ca1f7 Improve wording (#2603) 2023-11-06 12:15:42 +01:00
234c4fd511 Add CoinGecko (#2611) 2023-11-06 08:33:23 +01:00
669f1fb60c Feature/add database migration to reset account type in account table (#2602)
* Set accountType to NULL

* Update changelog
2023-11-05 18:31:16 +01:00
52df0c62ab Release 2.18.0 (#2600) 2023-11-05 11:53:38 +01:00
e8e1bb83bf Fix get quotes in CoinGecko service (#2595)
* Fix get quotes in CoinGecko service

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-11-05 11:52:09 +01:00
45510702d0 Feature/add hacktoberfest 2023 debriefing blog post (#2599)
* Add blog post: Hacktoberfest 2023 Debriefing

* Update changelog
2023-11-05 11:36:52 +01:00
1b7e3a1e47 Feature/support activities import by isin for yahoo finance (#2597)
* Add support to import activities by isin

* Update changelog
2023-11-05 09:58:57 +01:00
35f98b9d2d Bugfix/handle failing database query for account find many (#2598)
* Handle issue with account.findMany() -> where: { id: { in: [ null ] } }

* Update changelog
2023-11-05 09:57:23 +01:00
e980aed9e7 Reorder functions (#2594) 2023-11-05 08:50:43 +01:00
d993067e9a Feature/extend personal finance tools pages 20231104 2 (#2591)
* Add Vyzer

* Add FinWise
2023-11-05 08:50:27 +01:00
3d09bfdb0c Feature/upgrade angular to version 16.2.12 (#2590)
* Add upgrade guides for Angular

* Upgrade Angular dependencies to version 16.2.12

* Update changelog
2023-11-04 11:59:49 +01:00
3fbc4f500f Add empty columns (#2589) 2023-11-04 10:39:36 +01:00
373201a98f Add major version to docker tags (#2586)
* Add major version to docker tags

* Update changelog
2023-11-04 10:38:35 +01:00
681f88f002 Clean up import (#2492) 2023-11-04 10:19:15 +01:00
8a523a981a Bugfix/fix fees on account level (#2588)
* Fix fees on account level

* Update changelog
2023-11-04 10:17:58 +01:00
81ded53363 Center membership card (#2582) 2023-11-04 10:17:35 +01:00
5272407af8 Feature/extend personal finance tools pages 20231104 (#2587)
* Introduce alias

* Add Rocket Money

* Add 8FIGURES
2023-11-04 10:17:17 +01:00
c48f89d117 Add empty columns (#2583) 2023-11-04 10:11:58 +01:00
046fdd3ae7 Release 2.17.0 (#2579) 2023-11-02 19:36:59 +01:00
e69c7a753c Feature/add edit exchange rate button to admin control (#2577)
* Ad edit button

* Update changelog
2023-11-02 19:35:03 +01:00
5191415b5a Add Intuit Mint (#2578) 2023-11-02 19:34:42 +01:00
a704378702 Refactor interface of getQuotes() to object (#2570) 2023-11-01 13:55:48 +01:00
cf7ce64de7 Bugfix/improve alignment of menu item icons (#2566)
* Improve alignment

* Update changelog
2023-10-31 14:01:33 +01:00
8c1b45f35b Bugfix/fix exception in webauthn page (#2564)
* Remove useBrowserAutofill option in startAuthentication()

* Update changelog
2023-10-30 19:23:55 +01:00
6ad1528d01 Feature/improve language localization for german 20231029 (#2565)
* Update locales

* Update changelog
2023-10-29 19:33:42 +01:00
4a6fbe4d30 Release 2.16.0 (#2563) 2023-10-29 08:59:00 +01:00
e31741f0c7 Add Capitally (#2562) 2023-10-29 08:57:33 +01:00
b26aa7f51d Feature/improve duplicate check in activities import (#2561)
* Allow different accounts

* Update changelog
2023-10-29 08:40:42 +01:00
c0fccd186f Feature/upgrade prisma to version 5.5.2 (#2560)
* Upgrade prisma to version 5.5.2

* Update changelog
2023-10-29 08:20:55 +01:00
a7baad10d1 Feature/improve import of historical market data (#2559)
* Improve historical market data import

* Update changelog
2023-10-28 21:08:44 +02:00
16f1b16e41 Feature/change checkboxes to slide toggles in admin control panel (#2551)
* Change checkboxes to slide toggles

* Update changelog
2023-10-28 20:46:44 +02:00
409ddc90ce Feature/improve language localization for german 20231028 (#2556)
* Improve localization

* Update changelog
2023-10-28 20:46:13 +02:00
95bc84956e Feature/localize keywords of meta data (#2555)
* Localize keywords

* Update changelog
2023-10-28 15:39:01 +02:00
20cefaba19 Improve usability and validation in cash balance transfer (#2552)
* Improve usability and validation in cash balance transfer

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-28 15:09:01 +02:00
379c651ce0 Add translation for product slogan (#2554) 2023-10-28 15:07:15 +02:00
7804c6879d Clean up (#2543) 2023-10-28 11:24:50 +02:00
de2255f9ba Release 2.15.0 (#2548) 2023-10-26 19:38:44 +02:00
e4ec5f213e Feature/extend personal finance tools pages 20231026 (#2547)
* Improve wording (vs)

* Improve breadcrumb (vs)

* Add Beanvest

* Add Wealthica

* Update locales
2023-10-26 19:36:34 +02:00
f3c2fb853d Upgrade to Nx 17 (#2545)
* Upgrade to Nx 17

* Update changelog
2023-10-26 19:35:56 +02:00
f5ad1d2d24 Feature/set validation rule to positive number in cash balance transfer (#2544)
* Add validation rule (positive number)

* Update changelog
2023-10-26 19:19:43 +02:00
0af37ca1d7 Extend asset profile dialog form (#2535)
* Extend asset profile dialog form

* Update changelog
2023-10-25 20:28:51 +02:00
2992a0da4c Verify current benchmark before loading it (#2541)
* Verify current benchmark before loading it

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-25 20:27:41 +02:00
2dcc7e161c Improve validation of activities import (#2496)
* Improve validation of activities import: expects positive values for fee, quantity and unitPrice

* Update changelog

---------

Co-authored-by: Rafael Claudio <rafacla@github.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-24 20:51:48 +02:00
fa627f686f Bugfix/fix chart for account excluded from analysis (#2534)
* Fix chart for account excluded from analysis

* Update changelog
2023-10-24 20:13:57 +02:00
0567083fc1 Feature/upgrade UUID to version 9.0.1 (#2537)
* Upgrade uuid to version 9.0.1

* Update changelog
2023-10-24 20:12:00 +02:00
3212efef17 Feature/upgrade yahoo finance2 to version 2.8.1 (#2536)
* Upgrade yahoo-finance2 to version 2.8.1

* Update changelog
2023-10-24 19:40:53 +02:00
6077e7c2f9 Feature/improve position detail dialog (#2532)
* Improve style and wording

* Update locales

* Update changelog
2023-10-23 20:50:40 +02:00
96b5dcfaf8 Create reusable currency selector component using mat-autocomplete (#2487)
* Create reusable currency selector component using mat-autocomplete

* Update changelog
2023-10-23 20:30:05 +02:00
c4e8e37884 Release 2.14.0 (#2530) 2023-10-21 20:07:40 +02:00
281d33f825 Feature/update oss friends 20231021 (#2529)
* Update OSS friends

* Improve style of sub title
2023-10-21 20:05:20 +02:00
5822e4d186 Bugfix/fix style of active page in header navigation (#2528)
* Fix style of active page

* Update changelog
2023-10-21 19:34:44 +02:00
cb166dcc78 Redirect to membership page (#2527) 2023-10-21 18:43:33 +02:00
4e7b7375a9 Feature/setup open figi (#2526)
* Setup OpenFIGI

* Update changelog
2023-10-21 18:12:50 +02:00
b8626c2086 Feature/change fees interest and search to general availability (#2525)
* Change features to general availability

* Fees on account level
* Interest on account level
* Search for a holding

* Update changelog

* Add documentation for experimental features
2023-10-21 10:25:05 +02:00
a59f9fa037 Feature/remove version from client (#2522)
* Remove version

* Update changelog
2023-10-21 09:41:07 +02:00
1666486940 Bugfix/trim text in i18n service (#2520)
* Trim text

* Update changelog
2023-10-20 22:36:03 +02:00
ac0ad48a65 Display invalid activity in csv import (#2460)
* Display invalid activity in csv import

* Update changelog
2023-10-20 22:07:57 +02:00
6a19eab425 Feature/improve membership card (#2517)
* Improve style

* Add animated border
2023-10-20 22:05:27 +02:00
750c627613 Feature/allow to edit market data of today (#2515)
* Allow to edit today's market data

* Update changelog
2023-10-20 17:37:55 +02:00
175 changed files with 31429 additions and 12943 deletions

View File

@ -21,6 +21,7 @@ jobs:
with:
images: ghostfolio/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU

1
.gitignore vendored
View File

@ -27,6 +27,7 @@
/.angular/cache
.env
.env.prod
.nx/cache
/.sass-cache
/connect.lock
/coverage

View File

@ -1,2 +1,3 @@
/.nx/cache
/dist
/test/import

View File

@ -5,6 +5,144 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.22.0 - 2023-11-11
### Added
- Added the platform icon to the account selectors in the cash balance transfer from one to another account
- Added the platform icon to the account selector of the create or edit activity dialog
### Changed
- Optimized the style of the carousel component on mobile for the testimonial section on the landing page
- Introduced action menus in the overview of the admin control panel
- Harmonized the name column in the historical market data table of the admin control panel
- Refactored the implementation of the data range functionality (`getRange()`) in the market data service
## 2.21.0 - 2023-11-09
### Changed
- Extended the system message
### Fixed
- Fixed the unit for the _Zen Mode_ in the overview tab of the home page
- Fixed an issue to get quotes in the _Financial Modeling Prep_ service
## 2.20.0 - 2023-11-08
### Changed
- Removed the loading indicator of the unit in the overview tab of the home page
- Improved the import of historical market data in the admin control panel
- Increased the timeout in the health check endpoint for data enhancers
- Increased the timeout in the health check endpoint for data providers
- Removed the account type from the `Account` database schema
## 2.19.0 - 2023-11-06
### Added
- Added a data migration to set `accountType` to `NULL` in the account database table
### Changed
- Improved the language localization for the _Fear & Greed Index_ (market mood)
- Improved the language localization for German (`de`)
### Fixed
- Improved the handling of derived currencies (`GBp`, `ILA`, `ZAc`)
## 2.18.0 - 2023-11-05
### Added
- Added support to import activities by `isin` in the _Yahoo Finance_ service
- Added a new tag with the major version to the docker image on _Docker Hub_
- Added a blog post: _Hacktoberfest 2023 Debriefing_
### Changed
- Upgraded `angular` from version `16.2.1` to `16.2.12`
### Fixed
- Fixed an issue to get quotes in the _CoinGecko_ service
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
## 2.17.0 - 2023-11-02
### Added
- Added a button to edit the exchange rates in the admin control panel
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the biometric authentication
- Fixed the alignment of the icons in various menus
## 2.16.0 - 2023-10-29
### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
- Improved the usability and validation in the cash balance transfer from one to another account
- Changed the checkboxes to slide toggles in the overview of the admin control panel
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
- Improved the date parsing in the import historical market data of the admin control panel
- Improved the localized meta data (keywords) in `html` files
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.4.2` to `5.5.2`
## 2.15.0 - 2023-10-26
### Added
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Improved the style and wording of the position detail dialog
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
- Upgraded `uuid` from version `9.0.0` to `9.0.1`
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
### Fixed
- Fixed the chart in the account detail dialog for accounts excluded from analysis
- Verified the current benchmark before loading it on the analysis page
## 2.14.0 - 2023-10-21
### Added
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
### Changed
- Moved the fees on account level feature from experimental to general availability
- Moved the interest on account level feature from experimental to general availability
- Moved the search for a holding from experimental to general availability
- Improved the error message in the activities import for `csv` files
- Removed the application version from the client
- Allowed to edit todays historical market data in the asset profile details dialog of the admin control panel
### Fixed
- Fixed the style of the active page in the header navigation
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
## 2.13.0 - 2023-10-20
### Added
@ -258,7 +396,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added health check endpoints for data enhancers
- Added a health check endpoint for data enhancers
### Changed
@ -434,7 +572,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers
- Disabled the caching in the health check endpoint for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
@ -822,7 +960,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a fallback to historical market data if a data provider does not provide live data
- Added a general health check endpoint
- Added health check endpoints for data providers
- Added a health check endpoint for data providers
### Changed

View File

@ -1,5 +1,17 @@
# Ghostfolio Development Guide
## Experimental Features
New functionality can be enabled using a feature flag switch from the user settings.
### Backend
Remove permission in `UserService` using `without()`
### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Git
### Rebase
@ -8,6 +20,12 @@
## Dependencies
### Angular
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### Nx
#### Upgrade

View File

@ -230,18 +230,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
| Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
| Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
#### Response

View File

@ -190,36 +190,46 @@ export class AccountController {
this.request.user.id
);
const currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
const accountFrom = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
const accountTo = accountsOfUser.find(({ id }) => {
return id === accountIdTo;
});
if (!accountFrom || !accountTo) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
if (accountFrom.id === accountTo.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
if (accountFrom.balance < balance) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
accountId: accountFrom.id,
amount: -balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
accountId: accountTo.id,
amount: balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
}

View File

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash';
export class CreateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -1,4 +1,4 @@
import { IsNumber, IsString } from 'class-validator';
import { IsNumber, IsPositive, IsString } from 'class-validator';
export class TransferBalanceDto {
@IsString()
@ -8,5 +8,6 @@ export class TransferBalanceDto {
accountIdTo: string;
@IsNumber()
@IsPositive()
balance: number;
}

View File

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash';
export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -7,7 +7,10 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -331,9 +334,9 @@ export class AdminController {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
date,
marketPrice,
symbol,
date: resetHours(parseISO(date)),
state: 'CLOSE'
})
);

View File

@ -23,7 +23,13 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import {
AssetSubClass,
DataSource,
Prisma,
Property,
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@ -94,9 +100,17 @@ export class AdminService {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return {
label1: DEFAULT_CURRENCY,
label2: currency,
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
@ -303,15 +317,21 @@ export class AdminService {
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
comment,
dataSource,
name,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
comment,
dataSource,
name,
scraperConfiguration,
symbol,
symbolMapping

View File

@ -1,11 +1,23 @@
import { Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
@IsOptional()
comment?: string;
@IsString()
@IsOptional()
name?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;

View File

@ -1,9 +1,9 @@
import { IsDate, IsNumber, IsOptional } from 'class-validator';
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto {
@IsDate()
@IsISO8601()
@IsOptional()
date?: Date;
date?: string;
@IsNumber()
marketPrice: number;

View File

@ -83,6 +83,7 @@ export class ImportService {
const isDuplicate = orders.some((activity) => {
return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) &&
@ -280,6 +281,9 @@ export class ImportService {
createdAt,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
id,
isin,
name,
@ -350,6 +354,9 @@ export class ImportService {
createdAt,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
id,
isin,
name,
@ -476,6 +483,7 @@ export class ImportService {
const date = parseISO(<string>(<unknown>dateString));
const isDuplicate = existingActivities.some((activity) => {
return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) &&
@ -509,6 +517,9 @@ export class ImportService {
comment: null,
countries: null,
createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined,
isin: null,
name: null,

View File

@ -15,7 +15,6 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import {
@ -58,7 +57,6 @@ export class InfoService {
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
let systemMessage: string;
const globalPermissions: string[] = [];
@ -104,10 +102,6 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
}
const isUserSignupEnabled =
@ -135,7 +129,6 @@ export class InfoService {
platforms,
statistics,
subscriptions,
systemMessage,
tags,
baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies()

View File

@ -13,7 +13,8 @@ import {
IsISO8601,
IsNumber,
IsOptional,
IsString
IsString,
Min
} from 'class-validator';
import { isString } from 'lodash';
@ -48,9 +49,11 @@ export class CreateOrderDto {
date: string;
@IsNumber()
@Min(0)
fee: number;
@IsNumber()
@Min(0)
quantity: number;
@IsString()
@ -64,6 +67,7 @@ export class CreateOrderDto {
type: Type;
@IsNumber()
@Min(0)
unitPrice: number;
@IsBoolean()

View File

@ -8,12 +8,12 @@ import {
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
IsString,
Min
} from 'class-validator';
import { isString } from 'lodash';
@ -47,12 +47,14 @@ export class UpdateOrderDto {
date: string;
@IsNumber()
@Min(0)
fee: number;
@IsString()
id: string;
@IsNumber()
@Min(0)
quantity: number;
@IsString()
@ -66,5 +68,6 @@ export class UpdateOrderDto {
type: Type;
@IsNumber()
@Min(0)
unitPrice: number;
}

View File

@ -61,6 +61,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
values.push({
date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
@ -74,6 +75,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
values.push({
date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
@ -25,30 +26,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
getRange: ({
dateRangeEnd,
dateRangeStart,
symbols
uniqueAssets
}: {
dateRangeEnd: Date;
dateRangeStart: Date;
symbols: string[];
uniqueAssets: UniqueAsset[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
dataSource: DataSource.YAHOO,
dataSource: uniqueAssets[0].dataSource,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: symbols[0]
symbol: uniqueAssets[0].symbol
},
{
createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO,
dataSource: uniqueAssets[0].dataSource,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: symbols[0]
symbol: uniqueAssets[0].symbol
}
]);
}
@ -134,6 +135,7 @@ describe('CurrentRateService', () => {
errors: [],
values: [
{
dataSource: 'YAHOO',
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'

View File

@ -2,7 +2,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
ResponseError,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten, isEmpty, uniqBy } from 'lodash';
@ -52,6 +56,7 @@ export class CurrentRateService {
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({
dataSource: dataGatheringItem.dataSource,
date: today,
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
@ -75,27 +80,30 @@ export class CurrentRateService {
);
}
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
({ dataSource, symbol }) => {
return { dataSource, symbol };
}
);
promises.push(
this.marketDataService
.getRange({
dateQuery,
symbols
uniqueAssets
})
.then((data) => {
return data.map((marketDataItem) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => {
return {
date: marketDataItem.date,
dataSource,
date,
symbol,
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
marketPrice,
currencies[symbol],
userCurrency
),
symbol: marketDataItem.symbol
)
};
});
})
@ -112,7 +120,7 @@ export class CurrentRateService {
};
if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) {
for (const { dataSource, symbol } of quoteErrors) {
try {
// If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => {
@ -121,6 +129,7 @@ export class CurrentRateService {
if (!value) {
value = {
dataSource,
symbol,
date: today,
marketPriceInBaseCurrency: 0

View File

@ -1,5 +1,6 @@
export interface GetValueObject {
import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset {
date: Date;
marketPriceInBaseCurrency: number;
symbol: string;
}

View File

@ -323,7 +323,8 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -335,6 +336,7 @@ export class PortfolioController {
dateRange,
filters,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id
});

View File

@ -372,20 +372,23 @@ export class PortfolioService {
filters,
impersonationId,
userCurrency,
userId
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId
userId,
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
@ -1110,12 +1113,14 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
userId
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
@ -1124,7 +1129,8 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId
userId,
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
@ -1174,7 +1180,8 @@ export class PortfolioService {
filters,
impersonationId,
userCurrency,
userId
userId,
withExcludedAccounts
});
const itemOfToday = historicalDataContainer.items.find((item) => {
@ -1763,7 +1770,7 @@ export class PortfolioService {
filters,
includeDrafts = false,
userId,
withExcludedAccounts
withExcludedAccounts = false
}: {
filters?: Filter[];
includeDrafts?: boolean;
@ -1851,7 +1858,7 @@ export class PortfolioService {
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts
withExcludedAccounts = false
}: {
filters?: Filter[];
orders: OrderWithAccount[];
@ -1885,9 +1892,13 @@ export class PortfolioService {
});
} else {
const accountIds = uniq(
orders.map(({ accountId }) => {
return accountId;
})
orders
.filter(({ accountId }) => {
return accountId;
})
.map(({ accountId }) => {
return accountId;
})
);
currentAccounts = await this.accountService.accounts({

View File

@ -104,7 +104,7 @@ export class SubscriptionController {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
);
}

View File

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

View File

@ -7,9 +7,14 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import {
User as IUser,
SystemMessage,
UserSettings
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
@ -48,6 +53,17 @@ export class UserService {
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
});
let systemMessage: SystemMessage;
const systemMessageProperty = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
if (systemMessageProperty?.targetGroups?.includes(subscription.type)) {
systemMessage = systemMessageProperty;
}
let tags = await this.tagService.getByUser(id);
if (
@ -61,6 +77,7 @@ export class UserService {
id,
permissions,
subscription,
systemMessage,
tags,
access: access.map((accessItem) => {
return {
@ -110,7 +127,9 @@ export class UserService {
updatedAt
} = await this.prismaService.user.findUnique({
include: {
Account: true,
Account: {
include: { Platform: true }
},
Analytics: true,
Settings: true,
Subscription: true
@ -164,10 +183,10 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
// currentPermissions = without(
// currentPermissions,
// permissions.xyz
// );
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
@ -233,8 +252,8 @@ export class UserService {
currentPermissions.push(permissions.impersonateAllUsers);
}
user.Account = sortBy(user.Account, (account) => {
return account.name;
user.Account = sortBy(user.Account, ({ name }) => {
return name.toLowerCase();
});
user.permissions = currentPermissions.sort();

View File

@ -54,10 +54,30 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -82,6 +102,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -94,6 +118,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -102,6 +130,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -110,6 +142,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -134,6 +170,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -166,10 +206,22 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -274,6 +326,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -308,10 +364,30 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -336,6 +412,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -348,6 +428,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -356,6 +440,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -364,6 +452,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -388,6 +480,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -420,10 +516,22 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -590,12 +698,32 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
@ -618,6 +746,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -630,6 +762,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -638,6 +774,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -646,6 +786,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -670,6 +814,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -702,10 +850,22 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -718,10 +878,30 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -746,6 +926,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -758,6 +942,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -766,6 +954,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -774,6 +966,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -798,6 +994,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -830,10 +1030,22 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -12,13 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio';
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
@ -35,47 +34,51 @@ try {
const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
},
'/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}`
title: `500 Stars - ${title}`
},
'/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}`
title: `Hacktoberfest 2022 - ${title}`
},
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}`
title: `The importance of tracking your personal finances - ${title}`
},
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}`
title: `Ghostfolio meets Umbrel - ${title}`
},
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1000 Stars on GitHub - ${titleShort}`
title: `Ghostfolio reaches 1000 Stars on GitHub - ${title}`
},
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
title: `Unlock your Financial Potential with Ghostfolio - ${title}`
},
'/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}`
title: `Exploring the Path to FIRE - ${title}`
},
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}`
title: `Ghostfolio joins OSS Friends - ${title}`
},
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
title: `Announcing Ghostfolio 2.0 - ${title}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
title: `Hacktoberfest 2023 - ${title}`
},
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
}
};
@ -128,7 +131,16 @@ export const HtmlTemplateMiddleware = async (
}),
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title
keywords: i18nService.getTranslation({
languageCode,
id: 'metaKeywords'
}),
title:
locales[path]?.title ??
`${title} ${i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
});
return response.send(indexHtml);

View File

@ -38,6 +38,7 @@ export class ConfigurationService {
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
OPEN_FIGI_API_KEY: str({ default: '' }),
PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),

View File

@ -164,6 +164,9 @@ export class DataGatheringService {
countries,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,
@ -178,6 +181,9 @@ export class DataGatheringService {
countries,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,
@ -189,6 +195,9 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,

View File

@ -5,6 +5,7 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -105,9 +106,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}

View File

@ -134,13 +134,17 @@ export class CoinGeckoService implements DataProviderInterface {
return DataSource.COINGECKO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
if (symbols.length <= 0) {
return response;
}
try {
@ -148,10 +152,10 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, requestTimeout);
const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join(
const quotes = await got(
`${this.URL}/simple/price?ids=${symbols.join(
','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{
@ -160,22 +164,20 @@ export class CoinGeckoService implements DataProviderInterface {
}
).json<any>();
for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = {
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open'
};
}
for (const symbol in quotes) {
response[symbol] = {
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open'
};
}
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return results;
return response;
}
public getTestSymbol() {

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common';
@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
@Module({
exports: [
DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
'DataEnhancers'
@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
imports: [ConfigurationModule, CryptocurrencyModule],
providers: [
DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
{
inject: [
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
provide: 'DataEnhancers',
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
useFactory: (openfigi, trackinsight, yahooFinance) => [
openfigi,
trackinsight,
yahooFinance
]
}
]
})

View File

@ -2,6 +2,7 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/in
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import ms from 'ms';
@Injectable()
export class DataEnhancerService {
@ -24,6 +25,7 @@ export class DataEnhancerService {
try {
const assetProfile = await dataEnhancer.enhance({
requestTimeout: ms('30 seconds'),
response: {
assetClass: 'EQUITY',
assetSubClass: 'ETF'

View File

@ -0,0 +1,87 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import got, { Headers } from 'got';
@Injectable()
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com';
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response,
symbol
}: {
requestTimeout?: number;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(
response.assetClass === 'EQUITY' &&
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
)
) {
return response;
}
const headers: Headers = {};
const { exchange, ticker } = parseSymbol({
symbol,
dataSource: response.dataSource
});
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
headers['X-OPENFIGI-APIKEY'] =
this.configurationService.get('OPEN_FIGI_API_KEY');
}
let abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const mappings = await got
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
headers,
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
// @ts-ignore
signal: abortController.signal
})
.json<any[]>();
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
if (figi) {
response.figi = figi;
}
if (compositeFIGI) {
response.figiComposite = compositeFIGI;
}
if (shareClassFIGI) {
response.figiShareClass = shareClassFIGI;
}
}
return response;
}
public getName() {
return 'OPENFIGI';
}
public getTestSymbol() {
return undefined;
}
}

View File

@ -21,9 +21,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
};
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response,
symbol
}: {
requestTimeout?: number;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
@ -37,7 +39,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, requestTimeout);
const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,

View File

@ -1,6 +1,10 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -10,6 +14,7 @@ import {
Prisma,
SymbolProfile
} from '@prisma/client';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@ -71,9 +76,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
}
public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response,
symbol
}: {
requestTimeout?: number;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
@ -156,7 +163,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
const response: Partial<SymbolProfile> = {};
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
let symbol = aSymbol;
if (isISIN(symbol)) {
try {
const { quotes } = await yahooFinance.search(symbol);
if (quotes.length === 1) {
symbol = quotes[0].symbol;
}
} catch {}
} else {
symbol = this.convertToYahooFinanceSymbol(symbol);
}
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
@ -176,7 +196,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
response.symbol = assetProfile.price.symbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = [];

View File

@ -17,6 +17,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber } from 'lodash';
import ms from 'ms';
@Injectable()
export class DataProviderService {
@ -52,6 +53,7 @@ export class DataProviderService {
symbol
}
],
requestTimeout: ms('30 seconds'),
useCache: false
});
@ -236,9 +238,11 @@ export class DataProviderService {
public async getQuotes({
items,
requestTimeout,
useCache = true
}: {
items: UniqueAsset[];
requestTimeout?: number;
useCache?: boolean;
}): Promise<{
[symbol: string]: IDataProviderResponse;
@ -311,7 +315,9 @@ export class DataProviderService {
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push(
promise.then(async (result) => {

View File

@ -131,28 +131,34 @@ export class EodHistoricalDataService implements DataProviderInterface {
return DataSource.EOD_HISTORICAL_DATA;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const symbols = aSymbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return {};
return response;
}
const eodHistoricalDataSymbols = symbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, requestTimeout);
const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
this.apiKey
}&fmt=json&s=${symbols.join(',')}`,
}&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
{
// @ts-ignore
signal: abortController.signal
@ -160,10 +166,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>();
const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
const searchResponse = await Promise.all(
symbols
eodHistoricalDataSymbols
.filter((symbol) => {
return !symbol.endsWith('.FOREX');
})
@ -176,7 +184,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return items[0];
});
const response = quotes.reduce(
response = quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }

View File

@ -113,13 +113,17 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return DataSource.FINANCIAL_MODELING_PREP;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
if (symbols.length <= 0) {
return response;
}
try {
@ -127,18 +131,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
}, requestTimeout);
const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
const quotes = await got(
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>();
for (const { price, symbol } of response) {
results[symbol] = {
for (const { price, symbol } of quotes) {
response[symbol] = {
currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP,
@ -150,7 +154,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
Logger.error(error, 'FinancialModelingPrepService');
}
return results;
return response;
}
public getTestSymbol() {

View File

@ -7,6 +7,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -99,18 +100,22 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
symbols.map((symbol) => {
return {
symbol,
dataSource: this.getName()
@ -129,7 +134,7 @@ export class GoogleSheetsService implements DataProviderInterface {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
if (symbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {

View File

@ -2,9 +2,11 @@ import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface {
enhance({
requestTimeout,
response,
symbol
}: {
requestTimeout?: number;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>>;

View File

@ -36,9 +36,13 @@ export interface DataProviderInterface {
getName(): DataSource;
getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }>;
getQuotes({
requestTimeout,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
getTestSymbol(): string;

View File

@ -133,18 +133,22 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
if (symbols.length <= 0) {
return response;
}
try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
symbols.map((symbol) => {
return { symbol, dataSource: this.getName() };
})
);
@ -154,10 +158,10 @@ export class ManualService implements DataProviderInterface {
orderBy: {
date: 'desc'
},
take: aSymbols.length,
take: symbols.length,
where: {
symbol: {
in: aSymbols
in: symbols
}
}
});

View File

@ -87,15 +87,19 @@ export class RapidApiService implements DataProviderInterface {
return DataSource.RAPID_API;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (symbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();

View File

@ -6,7 +6,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -30,7 +33,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const { assetClass, assetSubClass, currency, name } =
const { assetClass, assetSubClass, currency, name, symbol } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
return {
@ -38,8 +41,8 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass,
currency,
name,
dataSource: this.getName(),
symbol: aSymbol
symbol,
dataSource: this.getName()
};
}
@ -156,20 +159,24 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
const yahooFinanceSymbols = symbols.map((symbol) =>
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
);
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
let quotes: Pick<
Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'

View File

@ -95,6 +95,30 @@ export class ExchangeRateDataService {
const [currency1, currency2] = symbol.match(/.{1,3}/g);
const [date] = Object.keys(result[symbol]);
// Add derived currencies
if (currency2 === 'GBP') {
resultExtended[`${currency1}GBp`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ILS') {
resultExtended[`${currency1}ILA`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ZAR') {
resultExtended[`${currency1}ZAc`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
}
// Calculate the opposite direction
resultExtended[`${currency2}${currency1}`] = {
[date]: {

View File

@ -38,7 +38,7 @@ export class I18nService {
);
}
return translatedText;
return translatedText.trim();
}
private loadFiles() {

View File

@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
OPEN_FIGI_API_KEY: string;
PORT: number;
RAPID_API_API_KEY: string;
REDIS_HOST: string;

View File

@ -59,10 +59,10 @@ export class MarketDataService {
public async getRange({
dateQuery,
symbols
uniqueAssets
}: {
dateQuery: DateQuery;
symbols: string[];
uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> {
return await this.prismaService.marketData.findMany({
orderBy: [
@ -74,10 +74,17 @@ export class MarketDataService {
}
],
where: {
date: dateQuery,
symbol: {
in: symbols
}
OR: uniqueAssets.map(({ dataSource, symbol }) => {
return {
AND: [
{
dataSource,
symbol,
date: dateQuery
}
]
};
})
}
});
}

View File

@ -86,14 +86,24 @@ export class SymbolProfileService {
}
public updateSymbolProfile({
assetClass,
assetSubClass,
comment,
dataSource,
name,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({
data: { comment, scraperConfiguration, symbolMapping },
data: {
assetClass,
assetSubClass,
comment,
name,
scraperConfiguration,
symbolMapping
},
where: { dataSource_symbol: { dataSource, symbol } }
});
}

View File

@ -1,6 +1,6 @@
<header>
<div
*ngIf="canCreateAccount || (info?.systemMessage && user)"
*ngIf="canCreateAccount || user?.systemMessage"
class="info-message-container"
>
<div class="info-message-inner-container position-fixed w-100">
@ -19,11 +19,11 @@
</div></a
>
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
*ngIf="!canCreateAccount && user?.systemMessage"
class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()"
(click)="onClickSystemMessage()"
>
{{ info.systemMessage }}
{{ user.systemMessage.message }}
</div>
</div>
</div>
@ -165,7 +165,6 @@
<div class="row text-center">
<div class="col">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
{{ version }}
</div>
</div>

View File

@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
@ -157,10 +155,7 @@ export class AppComponent implements OnDestroy, OnInit {
);
this.hasInfoMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.canCreateAccount || !!this.user?.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme);
@ -168,12 +163,16 @@ export class AppComponent implements OnDestroy, OnInit {
});
}
public onCreateAccount() {
this.tokenStorageService.signOut();
public onClickSystemMessage() {
if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink);
} else {
alert(this.user.systemMessage.message);
}
}
public onShowSystemMessage() {
alert(this.info.systemMessage);
public onCreateAccount() {
this.tokenStorageService.signOut();
}
public onSignOut() {

View File

@ -116,7 +116,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
type: 'ACCOUNT'
}
],
range: 'max'
range: 'max',
withExcludedAccounts: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {

View File

@ -2,6 +2,7 @@
<button
class="align-items-center d-flex"
mat-stroked-button
[disabled]="dataSource?.data.length < 2"
(click)="onTransferBalance()"
>
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
@ -253,16 +254,20 @@
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button
mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)"
>
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>

View File

@ -9,7 +9,11 @@
[showYAxis]="true"
[symbol]="symbol"
></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
class="d-flex"
[hidden]="!marketData.length > 0"
>
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
<div

View File

@ -28,7 +28,6 @@
&.today {
background-color: rgba(var(--palette-accent-500), 1);
cursor: default;
}
}
}

View File

@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => {
this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value: marketDataItem.marketPrice
date: format(date, DATE_FORMAT),
value: marketPrice
};
});
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{
date,

View File

@ -57,10 +57,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public onUpdate() {
this.adminService
.putMarketData({
.postMarketData({
dataSource: this.data.dataSource,
date: this.data.date,
marketData: { marketPrice: this.data.marketPrice },
marketData: {
marketData: [
{
date: this.data.date.toISOString(),
marketPrice: this.data.marketPrice
}
]
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -20,6 +20,7 @@ import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { isUUID } from 'class-validator';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@ -83,7 +84,7 @@ export class AdminMarketDataComponent
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns = [
'symbol',
'nameWithSymbol',
'dataSource',
'assetClass',
'assetSubClass',
@ -97,6 +98,7 @@ export class AdminMarketDataComponent
];
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public isUUID = isUUID;
public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;

View File

@ -28,6 +28,24 @@
</td>
</ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="symbol"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="text-truncate">{{ element.name }}</div>
<div *ngIf="!isUUID(element.symbol)">
<small class="text-muted">{{ element.symbol | gfSymbol }}</small>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
@ -143,12 +161,24 @@
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button
mat-menu-item
[disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
>
<ng-container i18n>Delete</ng-container>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>

View File

@ -6,6 +6,7 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -20,6 +21,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfActivitiesFilterModule,
GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule,
GfSymbolModule,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,

View File

@ -6,22 +6,28 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import {
AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@ -33,19 +39,30 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
public assetClass: string;
public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '',
historicalData: this.formBuilder.group({
csvString: ''
}),
name: ['', Validators.required],
scraperConfiguration: '',
symbolMapping: ''
});
public assetSubClass: string;
public assetProfileSubClass: string;
public benchmarks: Partial<SymbolProfile>[];
public countries: {
[code: string]: { name: string; value: number };
};
public historicalDataAsCsvString: string;
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
@ -64,7 +81,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder
private formBuilder: FormBuilder,
private snackBar: MatSnackBar
) {}
public ngOnInit(): void {
@ -74,9 +92,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -86,8 +101,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile;
this.assetClass = translate(this.assetProfile?.assetClass);
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id;
@ -114,7 +129,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
this.assetProfileForm.setValue({
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '',
historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
},
name: this.assetProfile.name ?? this.assetProfile.symbol,
scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {}
),
@ -146,26 +167,46 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
}).data;
try {
const marketData = csvToJson(
this.assetProfileForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data;
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseISO(date) };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseDate(date).toISOString() };
})
},
symbol: this.data.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: 3000
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initialize();
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: 3000 }
);
}
}
public onMarketDataChanged(withRefresh: boolean = false) {
@ -204,9 +245,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
scraperConfiguration,
symbolMapping,
comment: this.assetProfileForm.controls['comment'].value ?? null
symbolMapping
};
this.adminService

View File

@ -52,7 +52,7 @@
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
<div class="mt-3">
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
@ -60,11 +60,9 @@
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
placeholder="e.g. 20230601;1.61"
type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
@ -75,6 +73,7 @@
color="accent"
mat-flat-button
type="button"
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
@ -112,7 +111,11 @@
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
<gf-value
i18n
size="medium"
[hidden]="!assetProfileClass"
[value]="assetProfileClass"
>Asset Class</gf-value
>
</div>
@ -120,8 +123,8 @@
<gf-value
i18n
size="medium"
[hidden]="!assetSubClass"
[value]="assetSubClass"
[hidden]="!assetProfileSubClass"
[value]="assetProfileSubClass"
>Asset Sub Class</gf-value
>
</div>
@ -174,6 +177,38 @@
</ng-template>
</ng-container>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass.id"
>{{ assetClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass.id"
>{{ assetSubClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox

View File

@ -7,6 +7,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -26,6 +28,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
MatDialogModule,
MatInputModule,
MatMenuModule,
MatSelectModule,
MatSnackBarModule,
ReactiveFormsModule,
TextFieldModule
],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { environment } from '@ghostfolio/client/../environments/environment';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -13,7 +12,12 @@ import {
PROPERTY_SYSTEM_MESSAGE,
ghostfolioPrefix
} from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
import {
Coupon,
InfoItem,
SystemMessage,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
differenceInSeconds,
@ -40,6 +44,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public permissions = permissions;
public systemMessage: SystemMessage;
public transactionCount: number;
public userCount: number;
public user: User;
@ -150,7 +155,13 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onDeleteSystemMessage() {
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
const confirmation = confirm(
$localize`Do you really want to delete this system message?`
);
if (confirmation === true) {
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
}
}
public onFlushCache() {
@ -170,27 +181,36 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
}
public onReadOnlyModeChange(aEvent: MatCheckboxChange) {
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) {
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
value: aEvent.checked ? undefined : false
});
}
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onSetSystemMessage() {
const systemMessage = prompt($localize`Please set your system message:`);
const systemMessage = prompt(
$localize`Please set your system message:`,
JSON.stringify(
this.systemMessage ??
<SystemMessage>{
message: '⚒️ Scheduled maintenance in progress...',
targetGroups: ['Basic', 'Premium']
}
)
);
if (systemMessage) {
this.putAdminSetting({
key: PROPERTY_SYSTEM_MESSAGE,
value: systemMessage
value: JSON.parse(systemMessage)
});
}
}
@ -209,6 +229,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.systemMessage = settings[
PROPERTY_SYSTEM_MESSAGE
] as SystemMessage;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;

View File

@ -38,7 +38,7 @@
<div class="w-50">
<table>
<tr *ngFor="let exchangeRate of exchangeRates">
<td class="d-flex">
<td>
<gf-value
[locale]="user?.settings?.locale"
[value]="1"
@ -46,8 +46,9 @@
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
<td class="d-flex justify-content-end">
<td align="right">
<gf-value
class="d-inline-block"
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
@ -56,13 +57,49 @@
<td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
class="h-100 mx-1 no-min-width px-2"
class="mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCurrency(exchangeRate.label2)"
[matMenuTriggerFor]="exchangeRateActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="trash-outline"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu
#exchangeRateActionsMenu="matMenu"
class="h-100 mx-1 no-min-width px-2"
xPosition="before"
>
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: exchangeRate.dataSource,
symbol: exchangeRate.symbol
}"
[routerLink]="['/admin', 'market-data']"
>
<span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="create-outline"
></ion-icon>
<span i18n>Edit</span>
</span>
</a>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</tr>
</table>
@ -81,28 +118,30 @@
<div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div>
<div class="w-50">
<mat-checkbox
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
(change)="onEnableUserSignupModeChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div>
<div class="w-50">
<mat-checkbox
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="info?.isReadOnlyMode"
(change)="onReadOnlyModeChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>System Message</div>
<div class="w-50">
<div *ngIf="info?.systemMessage">
<span>{{ info.systemMessage }}</span>
<div *ngIf="systemMessage" class="align-items-center d-flex">
<div class="text-truncate">{{ systemMessage | json }}</div>
<button
class="h-100 mx-1 no-min-width px-2"
mat-button
@ -113,6 +152,7 @@
</div>
<button
*ngIf="!info?.systemMessage"
class="mt-2"
color="accent"
mat-flat-button
(click)="onSetSystemMessage()"
@ -134,17 +174,34 @@
<table>
<tr *ngFor="let coupon of coupons">
<td class="text-monospace">{{ coupon.code }}</td>
<td class="d-flex justify-content-end pl-2">
{{ coupon.duration }}
</td>
<td class="pl-2 text-right">{{ coupon.duration }}</td>
<td>
<button
class="h-100 mx-1 no-min-width px-2"
class="mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
[matMenuTriggerFor]="couponActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="trash-outline"></ion-icon>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu
#couponActionsMenu="matMenu"
class="h-100 mx-1 no-min-width px-2"
xPosition="before"
>
<button
mat-menu-item
(click)="onDeleteCoupon(coupon.code)"
>
<span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</tr>
</table>

View File

@ -3,8 +3,10 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -18,10 +20,12 @@ import { AdminOverviewComponent } from './admin-overview.component';
FormsModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,
MatCardModule,
MatMenuModule,
MatSelectModule,
ReactiveFormsModule
MatSlideToggleModule,
ReactiveFormsModule,
RouterModule
],
providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -86,12 +86,16 @@
</button>
<mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button mat-menu-item (click)="onDeletePlatform(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>

View File

@ -66,12 +66,16 @@
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>

View File

@ -203,16 +203,20 @@
mat-menu-item
(click)="onImpersonateUser(element.id)"
>
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span>
</span>
</button>
<button
mat-menu-item
[disabled]="element.id === user?.id"
(click)="onDeleteUser(element.id)"
>
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span>
</span>
</button>
</mat-menu>
</td>

View File

@ -1,6 +1,5 @@
<button
*ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button
(click)="onClickCloseButton()"
>

View File

@ -1,7 +1,9 @@
:host {
display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0;
padding: 0 !important;
@media (min-width: 576px) {
padding: 0 !important;
}
}

View File

@ -6,6 +6,7 @@ import {
OnInit
} from '@angular/core';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
@Component({
selector: 'gf-fear-and-greed-index',
@ -24,9 +25,9 @@ export class FearAndGreedIndexComponent implements OnChanges, OnInit {
public ngOnInit() {}
public ngOnChanges() {
const { emoji, text } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
this.fearAndGreedIndexEmoji = emoji;
this.fearAndGreedIndexText = text;
this.fearAndGreedIndexText = translate(key);
}
}

View File

@ -1,5 +1,5 @@
<div class="container justify-content-center p-3">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<div class="mb-3 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined"

View File

@ -33,6 +33,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public showDetails = false;
public unit: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -76,6 +77,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
}
public onChangeDateRange(dateRange: DateRange) {

View File

@ -86,7 +86,6 @@
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
@ -95,6 +94,7 @@
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
[unit]="unit"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle

View File

@ -35,17 +35,7 @@
<span #value id="value"></span>
</div>
<div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }}
</div>
{{ unit }}
</div>
</div>
<div *ngIf="showDetails" class="row">

View File

@ -25,7 +25,6 @@ import { isNumber } from 'lodash';
styleUrls: ['./portfolio-performance.component.scss']
})
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() errors: ResponseError['errors'];
@Input() isAllTimeHigh: boolean;
@ -34,11 +33,10 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() locale: string;
@Input() performance: PortfolioPerformance;
@Input() showDetails: boolean;
@Input() unit: string;
@ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {}
public ngOnInit() {}
@ -50,8 +48,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
}
} else {
if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces:
@ -63,8 +59,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
separator: getNumberFormatGroup(this.locale)
}).start();
} else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,

View File

@ -155,16 +155,18 @@
[isDate]="true"
[locale]="data.locale"
[value]="firstBuyDate"
>First Buy Date</gf-value
>First Activity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="transactionCount"
>Transactions</gf-value
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
>
</div>
<div class="col-6 mb-3">

View File

@ -1,5 +1,6 @@
:host {
display: block;
align-items: center;
display: flex;
img {
border-radius: 0.2rem;

View File

@ -1,41 +1,42 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
<div class="row">
<div class="align-items-center container d-flex h-100 justify-content-center">
<div class="row w-100">
<div class="col">
<gf-membership-card
class="mb-5 mx-auto"
[expiresAt]="(user?.subscription?.expiresAt | date: defaultDateFormat) ?? ''"
[name]="user?.subscription?.type"
></gf-membership-card>
<div class="d-flex">
<div class="mx-auto">
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
>
<button color="primary" mat-flat-button (click)="onCheckout()">
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
>Upgrade</ng-container
>
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
>Renew</ng-container
>
</button>
<div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon"
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
}}</ng-container
>
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[name]="user?.subscription?.type"
></gf-membership-card>
<div
*ngIf="user?.subscription?.type === 'Basic'"
class="d-flex flex-column mt-5"
>
<ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
>
<button color="primary" mat-flat-button (click)="onCheckout()">
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
>Upgrade</ng-container
>
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
>Renew</ng-container
>
</button>
<div *ngIf="price" class="mt-1 text-center">
<ng-container *ngIf="coupon"
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
}}</ng-container
>
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<div class="align-items-center d-flex justify-content-center mt-4">
<a
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
class="mx-1"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span>
@ -46,7 +47,7 @@
></a>
<a
*ngIf="hasPermissionToUpdateUserSettings"
class="mr-2 my-2"
class="mx-1"
i18n
mat-stroked-button
[routerLink]=""

View File

@ -1,6 +1,7 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
height: 100%;
}
:host-context(.is-dark-theme) {

View File

@ -1,7 +1,7 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="h3 mb-4 text-center">
<h1 class="h3 line-height-1 mb-4 text-center">
<span class="d-none d-sm-block"
><ng-container i18n>Our</ng-container> OSS Friends</span
>

View File

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { environment } from '@ghostfolio/client/../environments/environment';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
@ -20,7 +19,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();

View File

@ -35,9 +35,6 @@
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForSubscription"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio Status"

View File

@ -13,8 +13,8 @@ import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { EMPTY, Subject, Subscription } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
@ -283,7 +283,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
data: {
accounts: this.accounts
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -301,7 +300,14 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
accountIdTo,
balance
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(
catchError(() => {
alert($localize`Oops, cash balance transfer has failed.`);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.fetchAccounts();
});

View File

@ -15,6 +15,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
import { Platform } from '@prisma/client';
import { Observable, Subject } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
@ -30,7 +31,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
})
export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public currencies: Currency[] = [];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[];
@ -46,7 +47,10 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.platforms = platforms;
this.accountForm = this.formBuilder.group({
@ -101,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value,
comment: this.accountForm.controls['comment'].value,
currency: this.accountForm.controls['currency'].value,
currency: this.accountForm.controls['currency'].value?.value,
id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value,

View File

@ -20,11 +20,10 @@
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
<div>
@ -37,7 +36,7 @@
(keydown.enter)="$event.stopPropagation()"
/>
<span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span
>{{ accountForm.controls['currency']?.value?.value }}</span
>
</mat-form-field>
</div>

View File

@ -7,8 +7,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@ -17,6 +17,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [
CommonModule,
FormsModule,
GfCurrencySelectorModule,
GfSymbolIconModule,
MatAutocompleteModule,
MatButtonModule,
@ -24,7 +25,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
]
})

View File

@ -4,7 +4,13 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { Account } from '@prisma/client';
@ -35,11 +41,16 @@ export class TransferBalanceDialog implements OnDestroy {
public ngOnInit() {
this.accounts = this.data.accounts;
this.transferBalanceForm = this.formBuilder.group({
balance: [0, Validators.required],
fromAccount: ['', Validators.required],
toAccount: ['', Validators.required]
});
this.transferBalanceForm = this.formBuilder.group(
{
balance: ['', Validators.required],
fromAccount: ['', Validators.required],
toAccount: ['', Validators.required]
},
{
validators: this.compareAccounts
}
);
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
this.currency = this.accounts.find((account) => {
@ -66,4 +77,13 @@ export class TransferBalanceDialog implements OnDestroy {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private compareAccounts(control: AbstractControl): ValidationErrors {
const accountFrom = control.get('fromAccount');
const accountTo = control.get('toAccount');
if (accountFrom.value === accountTo.value) {
return { invalid: true };
}
}
}

View File

@ -10,9 +10,17 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>From</mat-label>
<mat-select formControlName="fromAccount">
<mat-option *ngFor="let account of accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
<mat-option *ngFor="let account of accounts" [value]="account.id">
<div class="d-flex">
<gf-symbol-icon
*ngIf="account.Platform?.url"
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select>
</mat-form-field>
</div>
@ -20,9 +28,17 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>To</mat-label>
<mat-select formControlName="toAccount">
<mat-option *ngFor="let account of accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
<mat-option *ngFor="let account of accounts" [value]="account.id">
<div class="d-flex">
<gf-symbol-icon
*ngIf="account.Platform?.url"
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

@ -6,6 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { TransferBalanceDialog } from './transfer-balance-dialog.component';
@ -13,6 +14,7 @@ import { TransferBalanceDialog } from './transfer-balance-dialog.component';
declarations: [TransferBalanceDialog],
imports: [
CommonModule,
GfSymbolIconModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,

View File

@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2023-debriefing-page',
standalone: true,
templateUrl: './hacktoberfest-2023-debriefing-page.html'
})
export class Hacktoberfest2023DebriefingPageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
}

View File

@ -0,0 +1,283 @@
<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">Hacktoberfest 2023 Debriefing</h1>
<div class="mb-3 text-muted"><small>2023-11-05</small></div>
<img
alt="Hacktoberfest 2023 with Ghostfolio Teaser"
class="rounded w-100"
src="../assets/images/blog/hacktoberfest-2023.png"
title="Hacktoberfest 2023 with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
As <a href="https://hacktoberfest.com">Hacktoberfest</a> has come to
an end, its time to look back and share our learnings.
Hacktoberfest is a month-long celebration of open source software
(OSS) projects, their maintainers, and the entire community of
contributors. Each October, open source maintainers from all over
the world give extra attention to new contributors while guiding
them through their first pull requests on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
year the event celebrated its 10th anniversary. At Ghostfolio, we
have participated in the event for the
<a href="../en/blog/2023/09/hacktoberfest-2023">second time</a> this
year.
</p>
<p>
In this debriefing, well take a closer look at our journey during
Hacktoberfest, exploring the facts and figures, key takeaways, and
the impact on Ghostfolio.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Hacktoberfest with Ghostfolio</h2>
<p>
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web
application for managing personal finances. The software aggregates
your assets and empowers informed decision-making to help you
balance your portfolio or plan for future investments.
</p>
<p>
Our experience at Ghostfolio during Hacktoberfest 2023 has been
truly remarkable. Despite the absence of T-shirt rewards this year,
our expectations were exceeded in every way.
<a [routerLink]="routerLinkAbout">We</a> had the privilege of
collaborating with
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>20 talented developers</a
>
from around the world, each bringing their unique skills and
backgrounds to our fintech project.
</p>
<p>
<figure class="figure">
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<img
alt="Screenshot of the Ghostfolios Hacktoberfest 2023 Insights"
class="figure-img img-fluid rounded"
src="../assets/images/blog/hacktoberfest-2023-insights.png"
title="Screenshot of the Ghostfolios Hacktoberfest 2023 Insights"
/>
</a>
<figcaption class="figure-caption text-center">
Screenshot of the Ghostfolios Hacktoberfest 2023 Insights
</figcaption>
</figure>
</p>
<p>
All these contributions made during Hacktoberfest have a significant
impact on Ghostfolio. As many as 100 new
<a [routerLink]="routerLinkFeatures">features</a> and improvements
have been merged to enhance the user experience and software
platform management.
</p>
<p class="text-center">
<img
alt="Hacktoberfest 2023 Badges"
class="figure-img img-fluid rounded w-50"
src="../assets/images/blog/hacktoberfest-2023-badges.png"
title="Hacktoberfest 2023 Badges"
/>
</p>
<p>
The lessons learned through this global collaboration highlight the
collective spirit of the open source community, even without
traditional incentives. It serves as a driving force for Ghostfolio
to evolve into an exceptional piece of open source wealth management
software. Hacktoberfest 2023 has been an amazing and enlightening
journey for everyone involved.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Key Takeaways</h2>
<p>
Weve gathered some valuable takeaways from our experience to
consider for upcoming years. These insights can help us and fellow
open source projects make the most of Hacktoberfest:
</p>
<ul class="list-unstyled">
<li>
<h3 class="h5">Prepare early</h3>
<p>
Prospective contributors often begin looking for tasks as early
as the end of September. Preparing your project, organizing
issues, and setting clear goals in advance can help you attract
and engage more participants.
</p>
</li>
<li>
<h3 class="h5">Meet formal requirements</h3>
<p>
Ensure that your project aligns with Hacktoberfests formal
requirements and rules. Properly tag issues, provide clear
guidelines for contributions, and make sure your project is
accessible to newcomers.
</p>
</li>
<li>
<h3 class="h5">Allocate sufficient time</h3>
<p>
Being responsive and providing support to participants is
crucial. Allocate enough time to answer questions, review and
merge pull requests, and offer guidance to first-time
contributors. This level of support can significantly enhance
the experience for both contributors and maintainers.
</p>
</li>
<li>
<h3 class="h5">Provide clarity in descriptions</h3>
<p>
When creating issues for Hacktoberfest, be as clear as possible
in your descriptions. Including screenshots, links to code
examples, and detailed instructions can make it easier for
contributors to understand and complete the task successfully.
</p>
</li>
<li>
<h3 class="h5">Isolate tasks</h3>
<p>
Ideally, focus on tasks that are approachable for beginners, as
the setup and frameworks used can already be quite challenging.
This way, you can attract a wider range of contributors.
</p>
</li>
<li>
<h3 class="h5">Embrace learning</h3>
<p>
Understand that not every attempt will result in a successful
contribution. Its okay if a contributors pull request doesnt
make it into the project. Encourage a learning mindset and
provide constructive feedback, helping contributors grow and
improve over time.
</p>
</li>
</ul>
<p>
By following these takeaways, we can ensure a rewarding experience
for everyone taking part in future Hacktoberfest events.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Thank you</h2>
<p>
Our experience with Hacktoberfest truly showcases the magic of open
source. The sense of community, mentorship, and our shared
dedication to software development is what motivates us at
Ghostfolio. As we look forward to future collaborations to make
personal finance and investing more accessible, we simply want to
thank everyone involved.
</p>
<p>
Keep coding and sharing your learnings!<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Code</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</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">Development</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Feature</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">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">GitHub</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investing</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">Learning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Lessons learned</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Mindset</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">October</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">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Programming</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">Takeaways</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">User Experience</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">UX</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>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2023 Debriefing
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -163,6 +163,15 @@ const routes: Routes = [
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component'
).then((c) => c.Hacktoberfest2023PageComponent),
title: 'Hacktoberfest 2023'
},
{
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
title: 'Hacktoberfest 2023 Debriefing'
}
];

View File

@ -8,6 +8,32 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/11/hacktoberfest-2023-debriefing"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Hacktoberfest 2023 Debriefing
</div>
<div class="d-flex text-muted">2023-11-05</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">

View File

@ -5,6 +5,11 @@
Ghostfolio is a personal finance dashboard to keep track of your assets
like stocks, ETFs or cryptocurrencies across multiple platforms.
</li>
<li i18n="@@metaKeywords">
app, asset, cryptocurrency, dashboard, etf, finance, management,
performance, portfolio, software, stock, trading, wealth, web3
</li>
<li i18n="@@slogan">Open Source Wealth Management Software</li>
</ul>
</div>
</div>

View File

@ -327,7 +327,7 @@
<div class="col-md-8 offset-md-2">
<gf-carousel [aria-label]="'Testimonials'">
<div *ngFor="let testimonial of testimonials" gf-carousel-item>
<div class="d-flex px-3">
<div class="d-flex px-4">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"

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