Compare commits
102 Commits
Author | SHA1 | Date | |
---|---|---|---|
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 | |||
5db5d5e79a | |||
12aac101bd | |||
3a66ccdebe | |||
6a722d1bb7 | |||
7c9407d5dc | |||
8abb517ac6 | |||
dec1d89c5c | |||
24e9ecc3e2 | |||
4a1e05b8cd | |||
39d1a85267 | |||
7cb86de7af | |||
aa078588e8 | |||
fcef0a72d5 | |||
29987d3e2f | |||
6284b4dfe8 | |||
00342ca1f7 | |||
234c4fd511 | |||
669f1fb60c | |||
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 | |||
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 |
7
.github/workflows/build-code.yml
vendored
7
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@ -13,12 +16,12 @@ jobs:
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
|
3
.github/workflows/docker-image.yml
vendored
3
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
@ -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
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
.nx/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
|
@ -1,2 +1,3 @@
|
||||
/.nx/cache
|
||||
/dist
|
||||
/test/import
|
||||
|
174
CHANGELOG.md
174
CHANGELOG.md
@ -5,6 +5,170 @@ 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.26.0 - 2023-11-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prisma` from version `5.5.2` to `5.6.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
|
||||
|
||||
## 2.25.1 - 2023-11-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Black Friday 2023_
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `http-status-codes` from version `2.2.0` to `2.3.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handled reading items from missing transaction point while getting the position (`getPosition()`) in portfolio service
|
||||
|
||||
## 2.24.0 - 2023-11-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the "too many bind variables in prepared statement" issue of the data range functionality (`getRange()`) in the market data service
|
||||
|
||||
## 2.23.0 - 2023-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the benchmarks in the markets overview by 50-Day and 200-Day trends (experimental)
|
||||
- Set up the language localization for Polski (`pl`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data source validation in the activities import
|
||||
- Changed _Twitter_ to _𝕏_
|
||||
- Improved the selection in the twitter bot service
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.7.0` to `2.8.3`
|
||||
- Upgraded `prettier` from version `3.0.3` to `3.1.0`
|
||||
|
||||
## 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
|
||||
@ -67,7 +231,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Added support to transfer a part of the cash balance from one to another account
|
||||
- Extended the markets overview by benchmarks (date of last all time high)
|
||||
- Extended the benchmarks in the markets overview by the date of the last all time high
|
||||
- Added support to import historical market data in the admin control panel
|
||||
|
||||
### Changed
|
||||
@ -279,7 +443,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
|
||||
|
||||
@ -455,7 +619,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`
|
||||
|
||||
@ -843,7 +1007,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
|
||||
|
||||
@ -2307,7 +2471,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Added the _Ghostfolio_ trailer to the landing page
|
||||
- Extended the markets overview by benchmarks (current change to the all time high)
|
||||
- Extended the benchmarks in the markets overview by the current change to the all time high
|
||||
|
||||
## 1.151.0 - 24.05.2022
|
||||
|
||||
|
@ -20,6 +20,12 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Angular
|
||||
|
||||
#### Upgrade (minor versions)
|
||||
|
||||
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
24
README.md
24
README.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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'
|
||||
})
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -9,17 +9,21 @@ import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
calculateBenchmarkTrend
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@ -45,9 +49,34 @@ export class BenchmarkService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
BenchmarkResponse['benchmarks']
|
||||
> {
|
||||
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||
const historicalData = await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: { gte: subDays(new Date(), 400) }
|
||||
}
|
||||
});
|
||||
|
||||
const fiftyDayAverage = calculateBenchmarkTrend({
|
||||
historicalData,
|
||||
days: 50
|
||||
});
|
||||
const twoHundredDayAverage = calculateBenchmarkTrend({
|
||||
historicalData,
|
||||
days: 200
|
||||
});
|
||||
|
||||
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
||||
}
|
||||
|
||||
public async getBenchmarks({
|
||||
enableSharing = false,
|
||||
useCache = true
|
||||
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
if (useCache) {
|
||||
@ -62,9 +91,16 @@ export class BenchmarkService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||
enableSharing
|
||||
});
|
||||
|
||||
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||
[];
|
||||
const promisesBenchmarkTrends: Promise<{
|
||||
trend50d: BenchmarkTrend;
|
||||
trend200d: BenchmarkTrend;
|
||||
}>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
@ -73,10 +109,18 @@ export class BenchmarkService {
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
promisesAllTimeHighs.push(
|
||||
this.marketDataService.getMax({ dataSource, symbol })
|
||||
);
|
||||
promisesBenchmarkTrends.push(
|
||||
this.getBenchmarkTrends({ dataSource, symbol })
|
||||
);
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
@ -93,6 +137,7 @@ export class BenchmarkService {
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
@ -100,10 +145,12 @@ export class BenchmarkService {
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh.date,
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
}
|
||||
}
|
||||
},
|
||||
trend50d: benchmarkTrends[index].trend50d,
|
||||
trend200d: benchmarkTrends[index].trend200d
|
||||
};
|
||||
});
|
||||
|
||||
@ -118,14 +165,24 @@ export class BenchmarkService {
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
public async getBenchmarkAssetProfiles({
|
||||
enableSharing = false
|
||||
} = {}): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
)
|
||||
.filter((benchmark) => {
|
||||
if (enableSharing) {
|
||||
return benchmark.enableSharing;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
@ -33,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -83,6 +85,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)) &&
|
||||
@ -482,6 +485,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) &&
|
||||
@ -568,6 +572,12 @@ export class ImportService {
|
||||
index,
|
||||
{ currency, dataSource, symbol }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -1,5 +1,6 @@
|
||||
export interface GetValueObject {
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface GetValueObject extends UniqueAsset {
|
||||
date: Date;
|
||||
marketPriceInBaseCurrency: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
|
@ -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({
|
||||
@ -876,7 +879,7 @@ export class PortfolioService {
|
||||
let currentAveragePrice = 0;
|
||||
let currentQuantity = 0;
|
||||
|
||||
const currentSymbol = transactionPoints[j].items.find(
|
||||
const currentSymbol = transactionPoints[j]?.items.find(
|
||||
({ symbol }) => {
|
||||
return symbol === aSymbol;
|
||||
}
|
||||
@ -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({
|
||||
|
@ -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 }) => {
|
||||
|
@ -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
|
||||
@ -179,16 +198,18 @@ export class UserService {
|
||||
new Date(),
|
||||
user.createdAt
|
||||
);
|
||||
let frequency = 20;
|
||||
let frequency = 15;
|
||||
|
||||
if (daysSinceRegistration > 180) {
|
||||
if (daysSinceRegistration > 365) {
|
||||
frequency = 2;
|
||||
} else if (daysSinceRegistration > 180) {
|
||||
frequency = 3;
|
||||
} else if (daysSinceRegistration > 60) {
|
||||
frequency = 5;
|
||||
} else if (daysSinceRegistration > 30) {
|
||||
frequency = 10;
|
||||
frequency = 8;
|
||||
} else if (daysSinceRegistration > 15) {
|
||||
frequency = 15;
|
||||
frequency = 12;
|
||||
}
|
||||
|
||||
if (Analytics?.activityCount % frequency === 1) {
|
||||
@ -233,8 +254,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();
|
||||
|
||||
|
@ -54,18 +54,46 @@
|
||||
<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>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-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-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -74,6 +102,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -82,6 +114,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 +130,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 +142,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 +154,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 +182,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>
|
||||
@ -162,14 +214,34 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<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-whal</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 +346,14 @@
|
||||
<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/black-week-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,18 +388,46 @@
|
||||
<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>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-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-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -328,6 +436,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -336,6 +448,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 +464,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 +476,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 +488,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 +516,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>
|
||||
@ -416,14 +548,34 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<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-whal</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,18 +742,46 @@
|
||||
<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>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-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-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -610,6 +790,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -618,6 +802,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 +818,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 +830,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 +842,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 +870,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>
|
||||
@ -698,14 +902,34 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<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-whal</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,18 +942,46 @@
|
||||
<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>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-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-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -738,6 +990,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -746,6 +1002,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 +1018,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 +1030,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 +1042,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 +1070,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>
|
||||
@ -826,14 +1102,34 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<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-whal</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>
|
||||
@ -880,6 +1176,10 @@
|
||||
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -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,55 @@ 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 1’000 Stars on GitHub - ${titleShort}`
|
||||
title: `Ghostfolio reaches 1’000 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/black-week-2023': {
|
||||
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
|
||||
title: `Black Week 2023 - ${title}`
|
||||
},
|
||||
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
|
||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||
title: `Hacktoberfest 2023 Debriefing - ${title}`
|
||||
}
|
||||
};
|
||||
|
||||
@ -84,6 +91,9 @@ const isFileRequest = (filename: string) => {
|
||||
return true;
|
||||
} else if (
|
||||
filename.includes('auth/ey') ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-de.fi'
|
||||
) ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||
)
|
||||
@ -128,7 +138,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);
|
||||
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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'
|
||||
|
@ -15,9 +15,11 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
) {}
|
||||
|
||||
public async enhance({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
response: Partial<SymbolProfile>;
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
@ -45,7 +47,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, requestTimeout);
|
||||
|
||||
const mappings = await got
|
||||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||
|
@ -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`,
|
||||
|
@ -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 = [];
|
||||
|
@ -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) => {
|
||||
|
@ -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 }
|
||||
|
@ -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() {
|
||||
|
@ -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) => {
|
||||
|
@ -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>>;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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'
|
||||
|
@ -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]: {
|
||||
|
@ -59,12 +59,12 @@ export class MarketDataService {
|
||||
|
||||
public async getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
uniqueAssets
|
||||
}: {
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
uniqueAssets: UniqueAsset[];
|
||||
}): Promise<MarketData[]> {
|
||||
return await this.prismaService.marketData.findMany({
|
||||
return this.prismaService.marketData.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
date: 'asc'
|
||||
@ -74,24 +74,33 @@ export class MarketDataService {
|
||||
}
|
||||
],
|
||||
where: {
|
||||
dataSource: {
|
||||
in: uniqueAssets.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
date: dateQuery,
|
||||
symbol: {
|
||||
in: symbols
|
||||
in: uniqueAssets.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async marketDataItems(params: {
|
||||
select?: Prisma.MarketDataSelectScalar;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.MarketDataWhereUniqueInput;
|
||||
where?: Prisma.MarketDataWhereInput;
|
||||
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
|
||||
}): Promise<MarketData[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { select, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.marketData.findMany({
|
||||
select,
|
||||
cursor,
|
||||
orderBy,
|
||||
skip,
|
||||
|
@ -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 } }
|
||||
});
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export class TwitterBotService {
|
||||
symbolItem.marketPrice
|
||||
}/100)`;
|
||||
|
||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||
const benchmarkListing = await this.getBenchmarkListing();
|
||||
|
||||
if (benchmarkListing?.length > 1) {
|
||||
status += '\n\n';
|
||||
@ -78,29 +78,22 @@ export class TwitterBotService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getBenchmarkListing(aMax: number) {
|
||||
private async getBenchmarkListing() {
|
||||
const benchmarks = await this.benchmarkService.getBenchmarks({
|
||||
enableSharing: true,
|
||||
useCache: false
|
||||
});
|
||||
|
||||
const benchmarkListing: string[] = [];
|
||||
|
||||
for (const [index, benchmark] of benchmarks.entries()) {
|
||||
if (index > aMax - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
benchmarkListing.push(
|
||||
`${benchmark.name} ${(
|
||||
benchmark.performances.allTimeHigh.performancePercent * 100
|
||||
return benchmarks
|
||||
.map(({ marketCondition, name, performances }) => {
|
||||
return `${name} ${(
|
||||
performances.allTimeHigh.performancePercent * 100
|
||||
).toFixed(1)}%${
|
||||
benchmark.marketCondition !== 'NEUTRAL_MARKET'
|
||||
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
|
||||
marketCondition !== 'NEUTRAL_MARKET'
|
||||
? ' ' + resolveMarketCondition(marketCondition).emoji
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarkListing.join('\n');
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
@ -60,6 +60,10 @@
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"development-pl": {
|
||||
"baseHref": "/pl/",
|
||||
"localize": ["pl"]
|
||||
},
|
||||
"development-pt": {
|
||||
"baseHref": "/pt/",
|
||||
"localize": ["pt"]
|
||||
@ -170,6 +174,9 @@
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"development-pl": {
|
||||
"browserTarget": "client:build:development-pl"
|
||||
},
|
||||
"development-pt": {
|
||||
"browserTarget": "client:build:development-pt"
|
||||
},
|
||||
@ -193,6 +200,7 @@
|
||||
"messages.fr.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf",
|
||||
"messages.pl.xlf",
|
||||
"messages.pt.xlf",
|
||||
"messages.tr.xlf"
|
||||
]
|
||||
@ -235,6 +243,10 @@
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pl": {
|
||||
"baseHref": "/pl/",
|
||||
"translation": "apps/client/src/locales/messages.pl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
|
@ -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>
|
||||
@ -127,8 +127,11 @@
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>X (formerly Twitter)<ion-icon
|
||||
class="ml-1"
|
||||
name="open-outline"
|
||||
></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
@ -150,6 +153,11 @@
|
||||
<li>
|
||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||
</li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
|
||||
</li>
|
||||
-->
|
||||
<li>
|
||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||
</li>
|
||||
|
@ -155,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);
|
||||
|
||||
@ -166,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() {
|
||||
|
@ -116,7 +116,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
type: 'ACCOUNT'
|
||||
}
|
||||
],
|
||||
range: 'max'
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
],
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
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';
|
||||
@ -12,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,
|
||||
@ -39,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;
|
||||
@ -149,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() {
|
||||
@ -169,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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -208,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;
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -31,6 +31,7 @@
|
||||
<gf-benchmark
|
||||
[benchmarks]="benchmarks"
|
||||
[locale]="user?.settings?.locale"
|
||||
[user]="user"
|
||||
></gf-benchmark>
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -13,6 +13,7 @@
|
||||
<div class="d-flex mr-2">
|
||||
<gf-trend-indicator
|
||||
class="d-flex"
|
||||
size="large"
|
||||
[isLoading]="isLoading"
|
||||
[marketState]="position?.marketState"
|
||||
[range]="range"
|
||||
|
@ -1,5 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
border-radius: 0.2rem;
|
||||
|
@ -1,6 +1,5 @@
|
||||
<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">
|
||||
<div class="align-items-center d-flex flex-column">
|
||||
<gf-membership-card
|
||||
@ -34,7 +33,7 @@
|
||||
> <span i18n>per year</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex justfiy-content-center mt-4">
|
||||
<div class="align-items-center d-flex justify-content-center mt-4">
|
||||
<a
|
||||
*ngIf="!user?.subscription?.expiresAt"
|
||||
class="mx-1"
|
||||
|
@ -1,6 +1,7 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
|
@ -44,6 +44,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
'fr',
|
||||
'it',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'tr'
|
||||
];
|
||||
|
@ -74,6 +74,10 @@
|
||||
>Nederlands (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
|
@ -52,10 +52,10 @@
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack</a
|
||||
>
|
||||
community, tweet to
|
||||
community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
|
||||
>, send an e-mail to
|
||||
@ -70,14 +70,14 @@
|
||||
>GitHub</a
|
||||
>.
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<p class="align-items-center d-flex justify-content-center">
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
mat-icon-button
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>
|
||||
<ion-icon name="logo-twitter"></ion-icon>
|
||||
<span class="line-height-1 text-center w-100">𝕏</span>
|
||||
</a>
|
||||
<a
|
||||
*ngIf="user?.subscription?.type === 'Premium'"
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
]
|
||||
})
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -15,11 +15,13 @@
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Get 75% off on our
|
||||
<strong>Ghostfolio Premium</strong>
|
||||
<gf-premium-indicator
|
||||
class="d-inline-block ml-1"
|
||||
[enableLink]="false"
|
||||
></gf-premium-indicator>
|
||||
<span class="align-items-center d-inline-flex"
|
||||
><strong>Ghostfolio Premium</strong>
|
||||
<gf-premium-indicator
|
||||
class="d-inline-block ml-1"
|
||||
[enableLink]="false"
|
||||
></gf-premium-indicator
|
||||
></span>
|
||||
annual plan for ambitious investors who need the full picture of
|
||||
their financial assets.
|
||||
</p>
|
||||
|
@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [GfPremiumIndicatorModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-black-week-2023-page',
|
||||
standalone: true,
|
||||
templateUrl: './black-week-2023-page.html'
|
||||
})
|
||||
export class BlackWeek2023PageComponent {
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkPricing = ['/' + $localize`pricing`];
|
||||
}
|
@ -0,0 +1,161 @@
|
||||
<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">Black Week 2023</h1>
|
||||
<div class="mb-3 text-muted"><small>2023-11-19</small></div>
|
||||
<img
|
||||
alt="Black Week 2023 Teaser"
|
||||
class="rounded w-100"
|
||||
src="../assets/images/blog/black-week-2023.jpg"
|
||||
title="Black Week 2023"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Ambitious investors on a life-changing mission, this is your chance!
|
||||
Get 33% off on our
|
||||
<span class="align-items-center d-inline-flex"
|
||||
><strong>Ghostfolio Premium</strong>
|
||||
<gf-premium-indicator
|
||||
class="d-inline-block ml-1"
|
||||
[enableLink]="false"
|
||||
></gf-premium-indicator
|
||||
></span>
|
||||
annual plan with our exclusive Black Week deal. Elevate your
|
||||
financial strategy with the power of Ghostfolio designed to give you
|
||||
the full picture of your assets.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
<a
|
||||
href="https://ghostfol.io"
|
||||
title="Open Source Wealth Management Software"
|
||||
>Ghostfolio</a
|
||||
>
|
||||
is a modern web application to manage personal finances. This Open
|
||||
Source Software (OSS) dynamically aggregates your diverse assets
|
||||
including stocks, ETFs, cryptocurrencies, commodities, etc. and
|
||||
presents a comprehensive overview of your portfolio in real-time.
|
||||
Empower yourself to make informed, data-driven investment decisions
|
||||
with the robust analytics at your fingertips. Explore the numerous
|
||||
<a [routerLink]="routerLinkFeatures">features</a> to enhance your
|
||||
wealth management experience.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Snap the limited Black Week 2023 deal before it’s gone. For detailed
|
||||
information on plans and pricing, please visit our
|
||||
<a [routerLink]="routerLinkPricing">pricing page</a>.
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<a color="primary" mat-flat-button [routerLink]="routerLinkPricing"
|
||||
>Get the Deal</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">2023</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Black Friday</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Black Week</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cloud</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Deal</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">ETF</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">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">Ghostfolio Premium</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Hosting</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">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">Portfolio Tracker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Pricing</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">SaaS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Stock</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Subscription</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Web3</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Web 3.0</span>
|
||||
</li>
|
||||
</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"
|
||||
>
|
||||
Black Week 2023
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -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`];
|
||||
}
|
@ -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, it’s 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, we’ll 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 Ghostfolio’s Hacktoberfest 2023 Insights"
|
||||
class="figure-img img-fluid rounded"
|
||||
src="../assets/images/blog/hacktoberfest-2023-insights.png"
|
||||
title="Screenshot of the Ghostfolio’s Hacktoberfest 2023 Insights"
|
||||
/>
|
||||
</a>
|
||||
<figcaption class="figure-caption text-center">
|
||||
Screenshot of the Ghostfolio’s 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>
|
||||
We’ve 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 Hacktoberfest’s 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. It’s okay if a contributor’s pull request doesn’t
|
||||
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>
|
@ -163,6 +163,24 @@ 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-debriefing/hacktoberfest-2023-debriefing-page.component'
|
||||
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
|
||||
title: 'Hacktoberfest 2023 Debriefing'
|
||||
},
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
path: '2023/11/black-week-2023',
|
||||
loadComponent: () =>
|
||||
import('./2023/11/black-week-2023/black-week-2023-page.component').then(
|
||||
(c) => c.BlackWeek2023PageComponent
|
||||
),
|
||||
title: 'Black Week 2023'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,13 +1,67 @@
|
||||
<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" i18n>Blog</span>
|
||||
<small class="text-muted" i18n
|
||||
>Discover the latest Ghostfolio updates and insights on personal
|
||||
finance</small
|
||||
>
|
||||
</h1>
|
||||
<mat-card
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
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/black-week-2023"
|
||||
>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Black Week 2023</div>
|
||||
<div class="d-flex text-muted">2023-11-19</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">
|
||||
<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">
|
||||
|
@ -233,7 +233,7 @@
|
||||
community,
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
|
||||
>,
|
||||
@ -259,10 +259,10 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack </a
|
||||
>community, tweet to
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
|
||||
>, send an e-mail to
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="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" i18n>Features</span>
|
||||
<small class="text-muted" i18n>
|
||||
Check out the numerous features of Ghostfolio to manage your wealth
|
||||
@ -245,7 +245,8 @@
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English, Dutch, French,
|
||||
German, Italian, Portuguese, Spanish and Turkish are currently
|
||||
German, Italian,
|
||||
<!-- Polish, -->Portuguese, Spanish and Turkish are currently
|
||||
supported.
|
||||
</p>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user