Compare commits

..

43 Commits

Author SHA1 Message Date
c8682a7393 Release 1.273.0 (#2014) 2023-05-28 10:31:37 +02:00
144b6b2211 Bugfix/handle undefined in decode data source (#2013)
* Handle undefined data source

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

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

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

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

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

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

* Update changelog

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update changelog
2023-05-09 20:33:15 +02:00
126 changed files with 3609 additions and 1937 deletions

View File

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

View File

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

View File

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

View File

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

27
.vscode/launch.json vendored
View File

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

View File

@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.273.0 - 2023-05-28
### Added
- Added a stepper to the activities import dialog
- Added a link to manage the benchmarks to the benchmark comparator
- Added support for localized routes
### Fixed
- Fixed an issue in the data source transformation
## 1.272.0 - 2023-05-26
### Added
- Added support to set an asset profile as a benchmark
### Changed
- Decreased the density of the `@angular/material` tables
- Improved the portfolio proportion chart component by supporting case insensitive names
- Improved the breadcrumb navigation style in the blog post pages for mobile
- Improved the error handling in the delete user endpoint
- Improved the style of the _Changelog & License_ button on the about page
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
## 1.271.0 - 2023-05-20
### Added
- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type
- Added a blog post: _Unlock your Financial Potential with Ghostfolio_
### Changed
- Improved the local number formatting in the value component
- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page
### Fixed
- Fixed the vertical alignment in the toggle component
## 1.270.1 - 2023-05-19
### Added
- Added the cash balance and the value of equity to the account detail dialog
- Added a check for duplicates to the preview step of the import dividends dialog
- Added an error message for duplicates to the preview step of the activities import
- Added a connection timeout to the environment variable `DATABASE_URL`
- Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime
### Changed
- Improved the mobile layout of the portfolio summary tab on the home page
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.13.0` to `4.14.1`
### Fixed
- Improved the _Select all_ activities checkbox state after importing activities including a duplicate
- Fixed an issue with the data source transformation in the import dividends dialog
- Fixed the _Storybook_ setup
## 1.269.0 - 2023-05-11
### Added
- Added `FINANCIAL_MODELING_PREP` as a new data source type
### Changed
- Improved the market price on the first buy date in the chart of the position detail dialog
- Restructured the admin control panel with a new settings tab
### Fixed
- Fixed an error that occurred while editing an activity caused by the cash balance update
## 1.268.0 - 2023-05-08
### Added
@ -210,7 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme
- Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
- Upgraded `bull` from version `4.10.2` to `4.10.4`

View File

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

View File

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

View File

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

View File

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

View File

@ -94,6 +94,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
)
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
}
if (

View File

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

View File

@ -1,7 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
Activity,
ActivityError
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -71,8 +74,25 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber();
const isDuplicate = orders.some((activity) => {
return (
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
Account,
error,
quantity,
value,
accountId: Account?.id,
@ -84,7 +104,6 @@ export class ImportService {
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
isDuplicate: false, // TODO: Use evaluated state
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
@ -205,7 +224,7 @@ export class ImportService {
userId
});
const activitiesMarkedAsDuplicates = await this.markActivitiesAsDuplicates({
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,
userId
});
@ -228,13 +247,13 @@ export class ImportService {
accountId,
comment,
date,
error,
fee,
isDuplicate,
quantity,
SymbolProfile: assetProfile,
type,
unitPrice
} of activitiesMarkedAsDuplicates) {
} of activitiesExtendedWithErrors) {
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
@ -283,7 +302,7 @@ export class ImportService {
updatedAt: new Date()
};
} else {
if (isDuplicate) {
if (error) {
continue;
}
@ -321,7 +340,7 @@ export class ImportService {
//@ts-ignore
activities.push({
...order,
isDuplicate,
error,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
@ -339,17 +358,7 @@ export class ImportService {
return activities;
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
}
return uniqueAccountIds.size === 1;
}
private async markActivitiesAsDuplicates({
private async extendActivitiesWithErrors({
activitiesDto,
userId
}: {
@ -389,12 +398,16 @@ export class ImportService {
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
accountId,
comment,
date,
error,
fee,
isDuplicate,
quantity,
type,
unitPrice,
@ -421,6 +434,16 @@ export class ImportService {
);
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
}
return uniqueAccountIds.size === 1;
}
private async validateActivities({
activitiesDto,
maxActivitiesToImport,

View File

@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
@ -16,19 +17,22 @@ import {
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import {
InfoItem,
Statistics,
Subscription
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { subDays } from 'date-fns';
import { format, subDays } from 'date-fns';
@Injectable()
export class InfoService {
@ -115,19 +119,28 @@ export class InfoService {
globalPermissions.push(permissions.createUserAccount);
}
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
return {
...info,
benchmarks,
demoAuthToken,
globalPermissions,
isReadOnlyMode,
platforms,
statistics,
subscriptions,
systemMessage,
tags,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: await this.getDemoAuthToken(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
currencies: this.exchangeRateDataService.getCurrencies()
};
}
@ -291,6 +304,7 @@ export class InfoService {
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
const uptime = await this.getUptime();
statistics = {
activeUsers1d,
@ -299,7 +313,8 @@ export class InfoService {
gitHubContributors,
gitHubStargazers,
newUsers30d,
slackCommunityUsers
slackCommunityUsers,
uptime
};
await this.redisCacheService.set(
@ -323,4 +338,36 @@ export class InfoService {
return JSON.parse(stripeConfig.value);
}
private async getUptime(): Promise<number> {
{
try {
const monitorId = (await this.propertyService.getByKey(
PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string;
const get = bent(
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90),
DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`,
'GET',
'json',
200,
{
Authorization: `Bearer ${this.configurationService.get(
'BETTER_UPTIME_API_KEY'
)}`
}
);
const { data } = await get();
return data.attributes.availability / 100;
} catch (error) {
Logger.error(error, 'InfoService');
return undefined;
}
}
}
}

View File

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

View File

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

View File

@ -333,7 +333,6 @@ export class OrderService {
order.SymbolProfile.currency,
userCurrency
),
isDuplicate: false,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,

View File

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

View File

@ -794,16 +794,6 @@ export class PortfolioService {
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
if (historicalData[aSymbol]) {
let j = -1;
for (const [date, { marketPrice }] of Object.entries(
@ -815,11 +805,16 @@ export class PortfolioService {
) {
j++;
}
let currentAveragePrice = 0;
let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find(
(item) => item.symbol === aSymbol
({ symbol }) => {
return symbol === aSymbol;
}
);
if (currentSymbol) {
currentAveragePrice = currentSymbol.quantity.eq(0)
? 0
@ -829,14 +824,25 @@ export class PortfolioService {
historicalDataArray.push({
date,
marketPrice,
averagePrice: currentAveragePrice,
marketPrice:
historicalDataArray.length > 0
? marketPrice
: currentAveragePrice,
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
}
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
return {

View File

@ -4,7 +4,7 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';

View File

@ -304,21 +304,29 @@ export class UserService {
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
try {
await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
} catch {}
await this.prismaService.account.deleteMany({
where: { userId: where.id }
});
try {
await this.prismaService.account.deleteMany({
where: { userId: where.id }
});
} catch {}
await this.prismaService.analytics.delete({
where: { userId: where.id }
});
try {
await this.prismaService.analytics.delete({
where: { userId: where.id }
});
} catch {}
await this.prismaService.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prismaService.order.deleteMany({
where: { userId: where.id }
});
} catch {}
try {
await this.prismaService.settings.delete({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,26 @@
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[currency]="currency"
[locale]="user?.settings?.locale"
[value]="balance"
>Cash Balance</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[currency]="currency"
[locale]="user?.settings?.locale"
[value]="equity"
>Equity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="accountType"
>Account Type</gf-value

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,17 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createPlatformDialog: true }"
[routerLink]="[]"
>
Add Platform
</a>
</div>
<table
class="gf-table w-100"
mat-table
@ -91,16 +102,4 @@
</table>
</div>
</div>
<div *ngIf="hasPermissionToCreatePlatform" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -2,11 +2,4 @@
:host {
display: block;
.fab-container {
position: fixed;
right: 2rem;
bottom: 4rem;
z-index: 999;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,6 @@
appearance="outline"
class="w-100 without-hint"
color="accent"
[hidden]="benchmarks?.length === 0"
>
<mat-label i18n>Compare with...</mat-label>
<mat-select
@ -28,6 +27,12 @@
[value]="symbolProfile.id"
>{{ symbolProfile.name }}</mat-option
>
<mat-option
*ngIf="hasPermissionToAccessAdminControl"
i18n
[routerLink]="['/admin', 'market-data']"
>Manage Benchmarks</mat-option
>
</mat-select>
</mat-form-field>
</div>

View File

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

View File

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

View File

@ -1,16 +1,16 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Time in Market</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Time in Market</div>
<div class="justify-content-end">
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Buy</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -19,9 +19,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Sell</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -33,9 +33,9 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Investment</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Investment</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -44,9 +44,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Gross Performance</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Absolute Gross Performance</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -55,9 +55,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Gross Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Gross Performance</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -70,8 +70,8 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{transaction} other {transactions}}
</div>
@ -88,9 +88,9 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Net Performance</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Absolute Net Performance</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -99,9 +99,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Net Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3" i18n>Net Performance</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -115,9 +115,9 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Total Assets</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Total Assets</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -127,9 +127,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Valuables</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Valuables</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -138,8 +138,8 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Emergency Fund</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Emergency Fund</div>
<div
class="align-items-center d-flex justify-content-end"
[ngClass]="{ 'cursor-pointer': hasPermissionToUpdateUserSettings }"
@ -158,9 +158,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buying Power</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Buying Power</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -169,9 +169,9 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Excluded from Analysis</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Excluded from Analysis</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -183,9 +183,9 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 font-weight-bold" i18n>Net Worth</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
@ -194,9 +194,11 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Annualized Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>
Annualized Performance
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -210,9 +212,9 @@
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Dividend</div>
<div class="d-flex justify-content-end">
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"

View File

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

View File

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

View File

@ -1,14 +1,13 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { environment } from '@ghostfolio/client/../environments/environment';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Statistics, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
@Component({
host: { class: 'page' },
selector: 'gf-about-page',
@ -16,6 +15,7 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;

View File

@ -7,8 +7,25 @@
Ghostfolio is a lightweight wealth management application for
individuals to keep track of stocks, ETFs or cryptocurrencies and make
solid, data-driven investment decisions. The source code is fully
available as open source software (OSS). The project has been
initiated by
available as
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>open source software</a
>
(OSS) under the
<a
href="https://www.gnu.org/licenses/agpl-3.0.html"
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we share aggregated
<a
href="https://ghostfol.io/{{ defaultLanguageCode }}/open"
title="Open Startup"
>key metrics</a
>
of the platforms performance. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
@ -127,6 +144,7 @@
<gf-value
size="large"
subLabel="(Last 24 hours)"
[locale]="user?.settings?.locale"
[value]="statistics?.activeUsers1d ?? '-'"
>Active Users</gf-value
>
@ -135,6 +153,7 @@
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.newUsers30d ?? '-'"
>New Users</gf-value
>
@ -143,6 +162,7 @@
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.activeUsers30d ?? '-'"
>Active Users</gf-value
>
@ -151,6 +171,7 @@
<a class="d-block" href="https://ghostfolio.slack.com">
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.slackCommunityUsers ?? '-'"
>Users in Slack community</gf-value
>
@ -163,6 +184,7 @@
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
@ -175,6 +197,7 @@
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubStargazers ?? '-'"
>Stars on GitHub</gf-value
>
@ -198,7 +221,7 @@
</div>
<div
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
[ngClass]="{ 'mx-auto': !hasPermissionForBlog }"
>
<a
class="py-4 w-100"

View File

@ -3,7 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { AdminJobsComponent } from '@ghostfolio/client/components/admin-jobs/admin-jobs.component';
import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component';
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
import { AdminPlatformComponent } from '@ghostfolio/client/components/admin-platform/platform.component';
import { AdminSettingsComponent } from '@ghostfolio/client/components/admin-settings/admin-settings.component';
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
@ -25,15 +25,15 @@ const routes: Routes = [
component: AdminOverviewComponent,
title: $localize`Admin Control`
},
{
path: 'settings',
component: AdminSettingsComponent,
title: $localize`Settings`
},
{
path: 'users',
component: AdminUsersComponent,
title: $localize`Users`
},
{
path: 'platforms',
component: AdminPlatformComponent,
title: $localize`Platforms`
}
],
component: AdminPageComponent,

View File

@ -30,18 +30,18 @@ export class AdminPageComponent implements OnDestroy, OnInit {
label: $localize`Overview`,
path: 'overview'
},
{ iconName: 'people-outline', label: $localize`Users`, path: 'users' },
{
iconName: 'briefcase-outline',
label: $localize`Platforms`,
path: 'platforms'
iconName: 'settings-outline',
label: $localize`Settings`,
path: 'settings'
},
{
iconName: 'server-outline',
label: $localize`Market Data`,
path: 'market-data'
},
{ iconName: 'flash-outline', label: $localize`Jobs`, path: 'jobs' }
{ iconName: 'flash-outline', label: $localize`Jobs`, path: 'jobs' },
{ iconName: 'people-outline', label: $localize`Users`, path: 'users' }
];
}

View File

@ -4,7 +4,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module';
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminSettingsModule } from '@ghostfolio/client/components/admin-settings/admin-settings.module';
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
import { CacheService } from '@ghostfolio/client/services/cache.service';
@ -20,7 +20,7 @@ import { AdminPageComponent } from './admin-page.component';
GfAdminJobsModule,
GfAdminMarketDataModule,
GfAdminOverviewModule,
GfAdminPlatformModule,
GfAdminSettingsModule,
GfAdminUsersModule,
MatTabsModule
],

View File

@ -14,6 +14,7 @@
gf-admin-jobs,
gf-admin-market-data,
gf-admin-overview,
gf-admin-settings,
gf-admin-users {
flex: 1 1 auto;
overflow-y: auto;

View File

@ -202,7 +202,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hallo Ghostfolio
</li>
</ol>

View File

@ -182,7 +182,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hello Ghostfolio
</li>
</ol>

View File

@ -179,7 +179,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio: First months in Open Source
</li>
</ol>

View File

@ -182,7 +182,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio meets Internet Identity
</li>
</ol>

View File

@ -208,7 +208,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
How do I get my finances in order?
</li>
</ol>

View File

@ -191,7 +191,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
500 Stars on GitHub
</li>
</ol>

View File

@ -177,7 +177,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2022
</li>
</ol>

View File

@ -137,7 +137,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Black Friday 2022
</li>
</ol>

View File

@ -167,7 +167,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
The importance of tracking your personal finances
</li>
</ol>

View File

@ -177,7 +177,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio auf Sackgeld.com vorgestellt
</li>
</ol>

View File

@ -199,7 +199,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio meets Umbrel
</li>
</ol>

View File

@ -244,7 +244,10 @@
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li aria-current="page" class="breadcrumb-item active">
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio reaches 1000 Stars on GitHub
</li>
</ol>

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { UnlockYourFinancialPotentialWithGhostfolioPageComponent } from './unlock-your-financial-potential-with-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: UnlockYourFinancialPotentialWithGhostfolioPageComponent,
path: '',
title: 'Unlock your Financial Potential with Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class UnlockYourFinancialPotentialWithGhostfolioRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-unlock-your-financial-potential-with-ghostfolio-page',
styleUrls: ['./unlock-your-financial-potential-with-ghostfolio-page.scss'],
templateUrl: './unlock-your-financial-potential-with-ghostfolio-page.html'
})
export class UnlockYourFinancialPotentialWithGhostfolioPageComponent {}

View File

@ -0,0 +1,244 @@
<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">Unlock your Financial Potential with Ghostfolio</h1>
<div class="mb-3 text-muted"><small>2023-05-20</small></div>
<img
alt="Unlock your financial potential with Ghostfolio Teaser"
class="border rounded w-100"
src="../assets/images/blog/20230520.jpg"
title="Unlock your financial potential with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
Managing personal finances effectively is crucial for those striving
for a secure future and financial independence. In todays digital
age, having a reliable wealth management software can greatly
simplify the process. Ghostfolio is a powerful
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>open source solution</a
>
for individuals trading stocks, ETFs, or cryptocurrencies on
multiple platforms. This article explores the key reasons why
Ghostfolio is the ideal choice for those embracing diversification,
pursuing a buy & hold strategy, and seeking portfolio insights while
valuing privacy.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Effortless Management for Multi-Platform Investors</h2>
<p>
Ghostfolio offers a holistic solution to efficiently monitor and
manage investment portfolios across multiple platforms. By
consolidating data from various accounts, Ghostfolio eliminates the
need to switch between platforms, saving users valuable time and
effort.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Empowering Buy & Hold Strategies</h2>
<p>
For those committed to a
<a [routerLink]="['/resources']">buy & hold strategy</a>, Ghostfolio
provides an intuitive interface to monitor long-term investments.
Users can track performance over time, gaining insights into
portfolio growth and stability. With strong visualizations and
reporting <a [routerLink]="['/features']">features</a>, Ghostfolio
equips users to make well-informed decisions aligned with their
long-term investment goals.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Deep Portfolio Insights</h2>
<p>
Understanding portfolio composition is vital for making informed
financial decisions. Ghostfolio provides comprehensive insights into
asset allocation, sector exposure, geographical diversification, and
individual asset performance. These detailed analytics empower users
to assess portfolio strengths and weaknesses, making necessary
adjustments to optimize their allocation.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Privacy and Data Ownership</h2>
<p>
In the age of growing data security concerns, Ghostfolio sets itself
apart by giving the highest priority to privacy and data ownership.
As an open-source software, Ghostfolio ensures that users retain
complete control over their financial data. By eliminating the need
to trust third-party platforms with sensitive information,
Ghostfolio offers peace of mind to those who value privacy and data
security.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Streamlined Minimalism for Financial Efficiency</h2>
<p>
Ghostfolio embraces a lightweight approach to personal finance
management, focusing on essential features without overwhelming
users. Its streamlined user interface and clean design provide a
seamless and clutter-free experience. This minimalist approach
enhances user satisfaction and boosts efficiency by eliminating
distractions and simplifying the financial management process.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Driving Financial Independence (FIRE)</h2>
<p>
Achieving
<a [routerLink]="['/resources']">financial independence</a>
including early retirement (FIRE) requires careful planning,
monitoring, and forecasting. Ghostfolios robust features equip
individuals with tools to analyze, optimize and simulate investment
strategies. By providing insights, performance tracking, and
portfolio analysis, Ghostfolio serves as a valuable companion in the
pursuit of financial freedom.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Farewell to Spreadsheet Hassles</h2>
<p>
While spreadsheets have traditionally been used to manage personal
finances, they can be time-consuming and prone to errors. Ghostfolio
offers a user-friendly alternative by automating data aggregation,
analysis, and reporting. Users can bid farewell to manual data entry
and complex formulas, relying instead on Ghostfolios user-friendly
and intuitive interface to efficiently manage their finances.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Your Path to Financial Success with Ghostfolio</h2>
<p>
Ghostfolio, the open-source personal finance software, provides a
wide range of benefits for individuals involved in trading stocks,
ETFs, or cryptocurrencies. Whether you are pursuing a buy & hold
strategy, seeking valuable portfolio insights, or diversifying
financial resources while prioritizing privacy and data ownership,
Ghostfolio proves to be an invaluable tool on your journey towards
unlocking your financial potential. Say goodbye to spreadsheets and
embrace the power of Ghostfolio for simplified, secure, and
successful financial management.
</p>
</section>
<section class="mb-4 py-3">
<h2 class="h4 mb-0 text-center">
Would you like to <strong>unlock</strong> your
<strong>financial potential</strong>?
</h2>
<p class="lead mb-2 text-center">
Ghostfolio empowers you to manage your personal finances
effectively.
</p>
<div class="text-center">
<a color="primary" href="https://ghostfol.io" mat-flat-button>
Get Started
</a>
</div>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Analysis</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Assets</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budgeting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Buy & Hold</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrencies</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Diversification</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETFs</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">FIRE</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">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Minimalism</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Monitoring</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">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Retirement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Spreadsheet</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</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"
>
Unlock your Financial Potential with Ghostfolio
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { UnlockYourFinancialPotentialWithGhostfolioRoutingModule } from './unlock-your-financial-potential-with-ghostfolio-page-routing.module';
import { UnlockYourFinancialPotentialWithGhostfolioPageComponent } from './unlock-your-financial-potential-with-ghostfolio-page.component';
@NgModule({
declarations: [UnlockYourFinancialPotentialWithGhostfolioPageComponent],
imports: [
CommonModule,
MatButtonModule,
RouterModule,
UnlockYourFinancialPotentialWithGhostfolioRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class UnlockYourFinancialPotentialWithGhostfolioPageModule {}

View File

@ -2,6 +2,32 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
<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/05/unlock-your-financial-potential-with-ghostfolio"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Unlock your Financial Potential with Ghostfolio
</div>
<div class="d-flex text-muted">2023-05-20</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">

View File

@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Statistics } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { OpenPageComponent } from './open-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: OpenPageComponent,
path: '',
title: 'Open Startup'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class OpenPageRoutingModule {}

View File

@ -0,0 +1,45 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Statistics, User } from '@ghostfolio/common/interfaces';
import { Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-open-page',
styleUrls: ['./open-page.scss'],
templateUrl: './open-page.html'
})
export class OpenPageComponent implements OnDestroy, OnInit {
public statistics: Statistics;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {
const { statistics } = this.dataService.fetchInfo();
this.statistics = statistics;
}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,126 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center">Open Startup</h3>
<div class="intro-container">
<p>
At Ghostfolio, transparency is at the core of our values. We publish
the source code as
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>open source software</a
>
(OSS) under the
<a
href="https://www.gnu.org/licenses/agpl-3.0.html"
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we openly share aggregated key metrics of the platforms
performance.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col">
<mat-card appearance="outlined">
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 24 hours)"
[locale]="user?.settings?.locale"
[value]="statistics?.activeUsers1d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.newUsers30d ?? '-'"
>New Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
size="large"
subLabel="(Last 30 days)"
[locale]="user?.settings?.locale"
[value]="statistics?.activeUsers30d ?? '-'"
>Active Users</gf-value
>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a class="d-block" href="https://ghostfolio.slack.com">
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.slackCommunityUsers ?? '-'"
>Users in Slack community</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.gitHubStargazers ?? '-'"
>Stars on GitHub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://hub.docker.com/r/ghostfolio/ghostfolio"
>
<gf-value
size="large"
[locale]="user?.settings?.locale"
[value]="statistics?.dockerHubPulls ?? '-'"
>Pulls on Docker Hub</gf-value
>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a class="d-block" href="https://status.ghostfol.io">
<gf-value
size="large"
subLabel="(Last 90 days)"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="2"
[value]="statistics?.uptime ?? '-'"
>Uptime</gf-value
>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { OpenPageRoutingModule } from './open-page-routing.module';
import { OpenPageComponent } from './open-page.component';
@NgModule({
declarations: [OpenPageComponent],
imports: [CommonModule, GfValueModule, MatCardModule, OpenPageRoutingModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class OpenPageModule {}

View File

@ -0,0 +1,19 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.intro-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -415,13 +415,14 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
: this.activityForm.controls['searchSymbol'].value.symbol,
tags: this.activityForm.controls['tags'].value,
type: this.activityForm.controls['type'].value,
unitPrice: this.activityForm.controls['unitPrice'].value,
updateAccountBalance:
this.activityForm.controls['updateAccountBalance'].value
unitPrice: this.activityForm.controls['unitPrice'].value
};
if (this.data.activity.id) {
(activity as UpdateOrderDto).id = this.data.activity.id;
} else {
(activity as CreateOrderDto).updateAccountBalance =
this.activityForm.controls['updateAccountBalance'].value;
}
this.dialogRef.close({ activity });

View File

@ -18,8 +18,12 @@
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="mb-1 without-hint w-100">
<div [ngClass]="{'mb-3': data.activity.id}">
<mat-form-field
appearance="outline"
class="w-100"
[ngClass]="{'mb-1 without-hint': !data.activity.id}"
>
<mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId">
<mat-option
@ -32,7 +36,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<div class="mb-3" [ngClass]="{'d-none': data.activity.id}">
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
>Update Cash Balance</mat-checkbox
>

View File

@ -0,0 +1,4 @@
export enum ImportStep {
UPLOAD_FILE = 0,
SELECT_ACTIVITIES = 1
}

View File

@ -1,3 +1,7 @@
import {
StepperOrientation,
StepperSelectionEvent
} from '@angular/cdk/stepper';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -8,6 +12,7 @@ import {
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatStepper } from '@angular/material/stepper';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -15,8 +20,10 @@ import { ImportActivitiesService } from '@ghostfolio/client/services/import-acti
import { Position } from '@ghostfolio/common/interfaces';
import { AssetClass } from '@prisma/client';
import { isArray, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { ImportStep } from './enums/import-step';
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@Component({
@ -29,12 +36,14 @@ export class ImportActivitiesDialog implements OnDestroy {
public accounts: CreateAccountDto[] = [];
public activities: Activity[] = [];
public details: any[] = [];
public deviceType: string;
public errorMessages: string[] = [];
public holdings: Position[] = [];
public isFileSelected = false;
public importStep: ImportStep = ImportStep.UPLOAD_FILE;
public maxSafeInteger = Number.MAX_SAFE_INTEGER;
public mode: 'DIVIDEND';
public selectedActivities: Activity[] = [];
public stepperOrientation: StepperOrientation;
public uniqueAssetForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
@ -43,6 +52,7 @@ export class ImportActivitiesDialog implements OnDestroy {
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private formBuilder: FormBuilder,
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
private importActivitiesService: ImportActivitiesService,
@ -50,6 +60,10 @@ export class ImportActivitiesDialog implements OnDestroy {
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.stepperOrientation =
this.deviceType === 'mobile' ? 'vertical' : 'horizontal';
this.uniqueAssetForm = this.formBuilder.group({
uniqueAsset: [undefined, Validators.required]
});
@ -116,7 +130,15 @@ export class ImportActivitiesDialog implements OnDestroy {
}
}
public onLoadDividends() {
public onImportStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === ImportStep.UPLOAD_FILE) {
this.importStep = ImportStep.UPLOAD_FILE;
} else if (event.selectedIndex === ImportStep.SELECT_ACTIVITIES) {
this.importStep = ImportStep.SELECT_ACTIVITIES;
}
}
public onLoadDividends(aStepper: MatStepper) {
this.uniqueAssetForm.controls['uniqueAsset'].disable();
const { dataSource, symbol } =
@ -130,19 +152,23 @@ export class ImportActivitiesDialog implements OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
this.isFileSelected = true;
aStepper.next();
this.changeDetectorRef.markForCheck();
});
}
public onReset() {
public onReset(aStepper: MatStepper) {
this.details = [];
this.errorMessages = [];
this.isFileSelected = false;
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.uniqueAssetForm.controls['uniqueAsset'].enable();
aStepper.reset();
}
public onSelectFile() {
public onSelectFile(aStepper: MatStepper) {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
@ -225,8 +251,11 @@ export class ImportActivitiesDialog implements OnDestroy {
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.isFileSelected = true;
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss();
aStepper.next();
this.changeDetectorRef.markForCheck();
}
};
@ -235,8 +264,10 @@ export class ImportActivitiesDialog implements OnDestroy {
input.click();
}
public updateSelection(data: Activity[]) {
this.selectedActivities = data;
public updateSelection(activities: Activity[]) {
this.selectedActivities = activities.filter(({ error }) => {
return !error;
});
}
public ngOnDestroy() {

View File

@ -5,123 +5,152 @@
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1 py-3" mat-dialog-content>
<ng-container *ngIf="!isFileSelected">
<ng-container *ngIf="mode === 'DIVIDEND'; else selectFile">
<form [formGroup]="uniqueAssetForm" (ngSubmit)="onLoadDividends()">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-option
*ngFor="let holding of holdings"
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
>{{ holding.name }}</mat-option
>
</mat-select>
</mat-form-field>
<div class="d-flex justify-content-center flex-column">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!uniqueAssetForm.valid"
>
<span i18n>Load Dividends</span>
</button>
</div>
</form>
</ng-container>
<ng-template #selectFile>
<div class="d-flex justify-content-center flex-column">
<button
class="py-4"
color="primary"
mat-stroked-button
(click)="onSelectFile()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
</button>
<p class="mb-0 mt-4 text-center">
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</p>
</div>
</ng-template>
</ng-container>
<ng-container *ngIf="isFileSelected">
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
<gf-activities-table
[activities]="activities"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[pageSize]="maxSafeInteger"
[showActions]="false"
[showCheckbox]="true"
[showFooter]="false"
[showSymbolColumn]="false"
(selectedActivities)="updateSelection($event)"
></gf-activities-table>
</ng-container>
<ng-template #errorMessage>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-2">
<button mat-button (click)="onReset()">
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<span i18n>Back</span>
</button>
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="isFileSelected" class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!selectedActivities?.length"
(click)="onImportActivities()"
<div class="flex-grow-1" mat-dialog-content>
<mat-stepper
#stepper
[animationDuration]="0"
[linear]="true"
[orientation]="stepperOrientation"
[selectedIndex]="importStep"
(selectionChange)="onImportStepChange($event)"
>
<ng-container i18n>Import</ng-container>
</button>
<mat-step [completed]="importStep === 0" [selected]="importStep === 0">
<ng-template i18n matStepLabel>Select File</ng-template>
<div class="pt-3">
<ng-container *ngIf="mode === 'DIVIDEND'; else selectFile">
<form
[formGroup]="uniqueAssetForm"
(ngSubmit)="onLoadDividends(stepper)"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-option
*ngFor="let holding of holdings"
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
>{{ holding.name }}</mat-option
>
</mat-select>
</mat-form-field>
<div class="d-flex flex-column justify-content-center">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!uniqueAssetForm.valid"
>
<span i18n>Load Dividends</span>
</button>
</div>
</form>
</ng-container>
<ng-template #selectFile>
<div class="d-flex flex-column justify-content-center">
<button
class="py-4"
color="primary"
mat-stroked-button
(click)="onSelectFile(stepper)"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
</button>
<p class="mb-0 mt-4 text-center">
<span class="mr-1" i18n
>The following file formats are supported:</span
>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
target="_blank"
>CSV</a
>
<span class="mx-1" i18n>or</span>
<a
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
target="_blank"
>JSON</a
>
</p>
</div>
</ng-template>
</div>
</mat-step>
<mat-step [completed]="importStep === 1" [selected]="importStep === 1">
<ng-template i18n matStepLabel>Select Activities</ng-template>
<div class="pt-3">
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
<gf-activities-table
*ngIf="importStep === 1"
[activities]="activities"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[pageSize]="maxSafeInteger"
[showActions]="false"
[showCheckbox]="true"
[showFooter]="false"
[showSymbolColumn]="false"
(selectedActivities)="updateSelection($event)"
></gf-activities-table>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="!selectedActivities?.length"
(click)="onImportActivities()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</ng-container>
<ng-template #errorMessage>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="d-flex justify-content-end mt-3">
<button mat-button (click)="onReset(stepper)">
<ng-container i18n>Back</ng-container>
</button>
<button
class="ml-1"
color="primary"
mat-flat-button
[disabled]="true"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</ng-template>
</div>
</mat-step>
</mat-stepper>
</div>
<gf-dialog-footer

View File

@ -6,6 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatStepperModule } from '@angular/material/stepper';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
@ -25,6 +26,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
MatExpansionModule,
MatFormFieldModule,
MatSelectModule,
MatStepperModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -5,6 +5,16 @@
color: rgba(var(--palette-primary-500), 1);
}
mat-stepper {
::ng-deep {
.mat-step-header {
&[aria-selected='false'] {
pointer-events: none;
}
}
}
}
.mat-expansion-panel {
background: none;
box-shadow: none;

View File

@ -405,6 +405,10 @@ export class DataService {
return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
}
public postBenchmark(benchmark: UniqueAsset) {
return this.http.post(`/api/v1/benchmark`, benchmark);
}
public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -1,6 +1,19 @@
User-agent: *
Allow: /
Disallow: /de/about/*
Disallow: /de/faq
Disallow: /de/markets
Disallow: /de/portfolio/*
Disallow: /de/pricing
Disallow: /de/register
Disallow: /de/resources
Disallow: /de/ueber-uns/datenschutzbestimmungen
Disallow: /en/about/privacy-policy
Disallow: /en/p/*
Disallow: /en/portfolio/*
Disallow: /portfolio/*
Disallow: /pricing/*
Disallow: /register/*
Disallow: /resources/*
Sitemap: https://ghostfol.io/sitemap.xml

View File

@ -6,102 +6,262 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/pricing</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/demo</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>2023-03-25T00:00:00+00:00</lastmod>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<lastmod>2023-05-27T00:00:00+00:00</lastmod>
</url>
</urlset>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -327,10 +327,13 @@ ngx-skeleton-loader {
.breadcrumb {
background-color: unset;
flex-wrap: nowrap;
padding: unset;
}
.breadcrumb-item {
flex-wrap: nowrap;
&.active {
color: unset;
}

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