Compare commits

...

41 Commits

Author SHA1 Message Date
4f2bbba782 Release 1.14.0 (#154) 2021-06-09 20:36:31 +02:00
9eb25f6c9e Feature/connect or create logic for symbol profile (#153)
* Add connectOrCreate logic

* Extend seed

* Update changelog
2021-06-09 20:35:02 +02:00
f74b00446c Feature/improve world map chart (#152)
* Improve world map chart

* Update changelog
2021-06-09 20:32:39 +02:00
beb7e6ec34 Release 1.13.0 (#151) 2021-06-08 22:02:11 +02:00
2eafc042ad Feature/add world map (#150)
* Add a global heat map

* Update changelog
2021-06-08 21:59:46 +02:00
74954bc51d Release 1.12.0 (#149) 2021-06-06 15:33:20 +02:00
6a03120225 Feature/add symbol profile model (#148)
* Add symbol profile model and positions by country chart

* Add positions by continent chart

* Fix tests

* Extend seed

* Update changelog
2021-06-06 15:31:28 +02:00
21504573b4 Release 1.11.0 (#147) 2021-06-05 17:30:59 +02:00
fabd912fba Setup initial prisma migration (#146) 2021-06-05 17:20:52 +02:00
00b42855b6 Feature/upgrade prisma to 2.24.1 (#145)
* Upgrade prisma

* Update changelog

* Update database push script
2021-06-05 17:19:38 +02:00
ef272360fb Feature/render average prices in position detail chart (#144)
* Render average buy prices

* Update changelog
2021-06-05 17:17:53 +02:00
026a5011d4 Feature/add account registration page (#141)
* Add account registration page

* Update changelog
2021-06-05 17:16:07 +02:00
aa4206af0e Feature/various frontend improvements 2 (#140)
* Change buttons to links

* Update changelog
2021-06-05 17:11:03 +02:00
7788465272 Release 1.10.1 (#139) 2021-06-02 21:24:27 +02:00
3066dfd805 Release 1.10.0 (#138) 2021-06-02 20:56:06 +02:00
34303163bc Various frontend improvements (#137)
* Various frontend improvements

* Clean up import
2021-06-02 20:54:12 +02:00
e7fbcd4fa0 Feature/extend pricing page (#130)
* Extend pricing page

* Feature/align pricing page with subscription model (#135)

* Align pricing page with subscription model

* Update changelog
2021-06-02 20:15:53 +02:00
7c22969de1 Feature/move tools to sub path (#125)
* Move tools to sub path

* Update changelog
2021-06-02 20:10:44 +02:00
6623bc0113 Release 1.9.0 (#136) 2021-06-01 21:44:46 +02:00
146b5201b5 Feature/make x ray rules order consistent (#134)
* Make order of X-ray rules consistent

* Update changelog
2021-06-01 21:40:32 +02:00
b021fbde59 Feature/refactor to format distance to now strict (#133)
* Change from formatDistanceToNow to formatDistanceToNowStrict

* Update changelog
2021-06-01 21:38:55 +02:00
ec046b81a7 Fix style (#132) 2021-06-01 21:35:47 +02:00
aea497154a Feature/prettify symbols in transaction filtering component (#131)
* Prettify generic scraper symbols

* Update changelog
2021-06-01 21:34:53 +02:00
dc736d53b4 Fix sorting (#129)
* Fix sorting
2021-06-01 21:33:56 +02:00
5957b33779 Feature/enable labels on the x axis of the investment chart (#128)
* Enable x-axis labels

* Update changelog
2021-05-30 20:39:37 +02:00
bafdce56ad add yarn build:all to .travis.yml (#127) 2021-05-27 22:32:10 +02:00
42a2d404e4 Fix type errors (#126) 2021-05-27 21:12:55 +02:00
11b2379d98 Feature/respect data source in data gathering (#107)
* Respect data source in data gathering

* Update changelog

* optimize fetching from multiple data sources (#123)

* optimize fetching from multiple data sources

* improve performance by executing data gathering promises in parallel

* removed unused imports

* rename hasHistoricalData to canHandle

* Sort imports

* Clean up

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-27 20:50:10 +02:00
c0657a2e9e Extend README.md (#124)
* Add contributions welcome badge
* Add features
* State technology stack precisely
2021-05-24 21:11:06 +02:00
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
117 changed files with 2007 additions and 705 deletions

2
.env
View File

@ -5,9 +5,9 @@ REDIS_HOST=localhost
REDIS_PORT=6379
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY=

View File

@ -8,3 +8,4 @@ before_script:
script:
- yarn format:check
- yarn test
- yarn build:all

View File

@ -5,6 +5,107 @@ 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.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Investments by continent and country
## 1.11.0 - 05.06.2021
### Added
- Added a dedicated page for the account registration
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
- Introduced the initial prisma migration
### Changed
- Changed the buttons to links (`<a>`) on the tools page
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
## 1.10.1 - 02.06.2021
### Fixed
- Fixed an optional type in the user interface
## 1.10.0 - 02.06.2021
### Changed
- Moved the tools to a sub path (`/tools`)
- Extended the pricing page and aligned with the subscription model
## 1.9.0 - 01.06.2021
### Added
- Added the year labels to the investment chart on the x-axis
### Changed
- Respected the data source attribute of the transactions model in the data management for historical data
- Prettified the generic scraper symbols in the transaction filtering component
- Changed to the strict mode of distance formatting between two given dates
### Fixed
- Fixed the sorting in various tables
- Made the order of the rules in the _X-ray_ section consistent
## 1.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the user table of the admin control panel
### Changed
- Improved the alignment in the user table of the admin control panel
## 1.5.0 - 22.05.2021
### Added

View File

@ -7,16 +7,15 @@
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/>
</a>
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/>
</a>
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p>
</div>
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
## Why Ghostfolio?
@ -43,10 +42,13 @@ Ghostfolio is for you if you are...
## Features
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
## Technology Stack
@ -54,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
### Backend
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
### Frontend
The frontend is built with [Angular](https://angular.io).
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started

View File

@ -108,7 +108,7 @@ export class AdminService {
createdAt: true,
id: true
},
take: 20,
take: 30,
where: {
NOT: {
Analytics: null

View File

@ -37,7 +37,9 @@ export class ExperimentalController {
);
}
return benchmarks;
return benchmarks.map(({ symbol }) => {
return symbol;
});
}
@Get('benchmarks/:symbol')

View File

@ -44,6 +44,7 @@ export class ExperimentalService {
fee: 0,
id: undefined,
platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined

View File

@ -132,12 +132,26 @@ export class OrderController {
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id

View File

@ -2,7 +2,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client';
import { DataSource, Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@ -53,6 +53,7 @@ export class OrderService {
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
@ -90,6 +91,7 @@ export class OrderService {
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}

View File

@ -315,18 +315,6 @@ export class PortfolioController {
impersonationUserId || this.request.user.id
);
let report = await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
return await portfolio.getReport();
}
}

View File

@ -11,6 +11,7 @@ import {
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import {
add,
format,
@ -19,6 +20,7 @@ import {
getYear,
isAfter,
isSameDay,
parse,
parseISO,
setDate,
setMonth,
@ -74,7 +76,8 @@ export class PortfolioService {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Account: true
Account: true,
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
@ -158,8 +161,8 @@ export class PortfolioService {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null,
value: portfolioItem.value || null
marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value - portfolioItem.investment ?? null
};
});
}
@ -214,6 +217,8 @@ export class PortfolioService {
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
@ -226,6 +231,7 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice;
let minPrice = marketPrice;
@ -233,9 +239,24 @@ export class PortfolioService {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of month
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot?.averagePrice;
}
}
historicalDataArray.push({
averagePrice,
date,
averagePrice: currentAveragePrice,
value: marketPrice
});
@ -289,7 +310,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[aSymbol],
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolio.getMinDate(),
new Date()
);

View File

@ -4,9 +4,10 @@ import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns';
import { add, isBefore } from 'date-fns';
const crypto = require('crypto');
@ -24,7 +25,8 @@ export class UserService {
alias,
id,
role,
Settings
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({
include: {
@ -43,6 +45,7 @@ export class UserService {
return {
alias,
id,
subscription,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
@ -54,11 +57,7 @@ export class UserService {
settings: {
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT
},
subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })),
type: 'Trial'
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
}
};
}
@ -66,26 +65,49 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const user = await this.prisma.user.findUnique({
include: { Account: true, Settings: true },
const userFromDatabase = await this.prisma.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
if (user?.Settings) {
if (!user.Settings.currency) {
const user: UserWithSettings = userFromDatabase;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
} else if (userFromDatabase) {
// Set default settings if needed
user.Settings = {
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(),
userId: user?.id,
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) {
const latestSubscription = userFromDatabase.Subscription.reduce(
(a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
}
return user;
}

View File

@ -1,4 +1,4 @@
import { Account, Currency, Platform } from '@prisma/client';
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
@ -12,6 +12,7 @@ export class Order {
private id: string;
private quantity: number;
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: OrderType;
private unitPrice: number;
@ -24,6 +25,7 @@ export class Order {
this.id = data.id || uuidv4();
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type;
this.unitPrice = data.unitPrice;
@ -58,6 +60,10 @@ export class Order {
return this.symbol;
}
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() {
return this.total;
}

View File

@ -189,6 +189,7 @@ describe('Portfolio', () => {
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
@ -223,6 +224,7 @@ describe('Portfolio', () => {
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
@ -290,6 +292,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -324,6 +327,7 @@ describe('Portfolio', () => {
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
@ -385,6 +389,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -401,6 +406,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
@ -461,6 +467,7 @@ describe('Portfolio', () => {
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
@ -477,6 +484,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -550,6 +558,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -566,6 +575,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
@ -582,6 +592,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,

View File

@ -8,7 +8,10 @@ import {
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
@ -127,6 +130,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
@ -139,6 +143,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
@ -204,6 +209,7 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -243,6 +249,21 @@ export class Portfolio implements PortfolioInterface {
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
@ -289,6 +310,7 @@ export class Portfolio implements PortfolioInterface {
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
@ -296,7 +318,12 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
transactionCount: portfolioItem.positions[symbol].transactionCount
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
};
});
@ -402,10 +429,10 @@ export class Portfolio implements PortfolioInterface {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskCurrentInvestment(
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskInitialInvestment(
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
@ -486,7 +513,13 @@ export class Portfolio implements PortfolioInterface {
.reduce((previous, current) => previous + current, 0);
}
public getOrders() {
public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders;
}
@ -538,6 +571,7 @@ export class Portfolio implements PortfolioInterface {
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})

View File

@ -5,6 +5,7 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
format,
@ -18,6 +19,7 @@ import {
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
@ -115,15 +117,13 @@ export class DataGatheringService {
}
}
public async gatherSymbols(
aSymbolsWithStartDate: { date: Date; symbol: string }[]
) {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
for (const { date, symbol } of aSymbolsWithStartDate) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[symbol],
[{ dataSource, symbol }],
date,
new Date()
);
@ -184,20 +184,24 @@ export class DataGatheringService {
}
}
public async getCustomSymbolsToGather(startDate?: Date) {
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
private getBenchmarksToGather(startDate: Date) {
const benchmarksToGather = benchmarks.map((symbol) => {
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
@ -205,6 +209,7 @@ export class DataGatheringService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX'
});
@ -213,16 +218,16 @@ export class DataGatheringService {
return benchmarksToGather;
}
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { symbol: true }
select: { dataSource: true, symbol: true }
});
const distinctOrdersWithDate = distinctOrders
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
.filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
})
@ -233,12 +238,15 @@ export class DataGatheringService {
};
});
const currencyPairsToGather = currencyPairs.map((symbol) => {
return {
symbol,
date: startDate
};
});
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
@ -252,24 +260,27 @@ export class DataGatheringService {
];
}
private async getSymbolsMax() {
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01'));
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
);
const currencyPairsToGather = currencyPairs.map((symbol) => {
return {
symbol,
date: startDate
};
});
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
}
);
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { date: true, symbol: true }
select: { dataSource: true, date: true, symbol: true }
});
return [

View File

@ -1,6 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import {
isCrypto,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
@ -14,15 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
export class DataProviderService implements DataProviderInterface {
export class DataProviderService {
public constructor(
private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService,
@ -121,79 +119,53 @@ export class DataProviderService implements DataProviderInterface {
}
public async getHistoricalRaw(
aSymbols: string[],
aDataGatheringItems: IDataGatheringItem[],
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
const filteredSymbols = aSymbols.filter((symbol) => {
return !isGhostfolioScraperApiSymbol(symbol);
});
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
filteredSymbols,
undefined,
from,
to
);
if (aSymbols.length === 1) {
const symbol = aSymbols[0];
if (
isCrypto(symbol) &&
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
) {
// Merge data from Yahoo with data from Alpha Vantage
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
[symbol],
undefined,
from,
to
const promises: Promise<{
data: { [date: string]: IDataProviderHistoricalResponse };
symbol: string;
}>[] = [];
for (const { dataSource, symbol } of aDataGatheringItems) {
const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) {
promises.push(
dataProvider
.getHistorical([symbol], undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol }))
);
return {
[symbol]: {
...dataOfYahoo[symbol],
...dataOfAlphaVantage[symbol]
}
};
} else if (isGhostfolioScraperApiSymbol(symbol)) {
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfGhostfolioScraperApi;
} else if (
isRakutenRapidApiSymbol(symbol) &&
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
) {
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfRakutenRapidApi;
}
}
return dataOfYahoo;
const allData = await Promise.all(promises);
for (const { data, symbol } of allData) {
result[symbol] = data;
}
return result;
}
public async search(aSymbol: string) {
return this.getDataProvider().search(aSymbol);
return this.getDataProvider(
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
).search(aSymbol);
}
private getDataProvider() {
switch (this.configurationService.get('DATA_SOURCES')[0]) {
private getDataProvider(providerName: DataSource) {
switch (providerName) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.GHOSTFOLIO:
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:

View File

@ -24,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
});
}
public canHandle(symbol: string) {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -1,4 +1,7 @@
import { getYesterday } from '@ghostfolio/common/helper';
import {
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(private prisma: PrismaService) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -1,4 +1,8 @@
import { getToday, getYesterday } from '@ghostfolio/common/helper';
import {
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
private readonly configurationService: ConfigurationService
) {}
public canHandle(symbol: string) {
return (
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -28,6 +28,10 @@ export class YahooFinanceService implements DataProviderInterface {
public constructor() {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -7,6 +7,8 @@ import {
} from './interfaces';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
getHistorical(

View File

@ -1,5 +1,5 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type';
@ -41,6 +41,7 @@ export interface IOrder {
id?: string;
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
type: OrderType;
unitPrice: number;
}
@ -65,6 +66,12 @@ export interface IDataProviderResponse {
url?: string;
}
export interface IDataGatheringItem {
dataSource: DataSource;
date?: Date;
symbol: string;
}
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -28,13 +28,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{
path: 'analysis',
loadChildren: () =>
import('./pages/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'auth',
loadChildren: () =>
@ -53,10 +46,10 @@ const routes: Routes = [
)
},
{
path: 'report',
path: 'register',
loadChildren: () =>
import('./pages/report/report-page.module').then(
(m) => m.ReportPageModule
import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule
)
},
{
@ -69,7 +62,28 @@ const routes: Routes = [
{
path: 'start',
loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
import('./pages/landing/landing-page.module').then(
(m) => m.LandingPageModule
)
},
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{
path: 'tools/analysis',
loadChildren: () =>
import('./pages/tools/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'tools/report',
loadChildren: () =>
import('./pages/tools/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'transactions',

View File

@ -4,6 +4,7 @@
[currentRoute]="currentRoute"
[info]="info"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
@ -11,13 +12,15 @@
<div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5">
<div class="col-md-6 offset-md-3">
<div
class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
<a [routerLink]="['/']">
<mat-card
class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</mat-card></a
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</div>
</div>
</div>
</div>
@ -25,7 +28,10 @@
<router-outlet></router-outlet>
</main>
<footer class="footer d-flex justify-content-center position-absolute w-100">
<footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center">
<div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -5,8 +5,6 @@
padding: 5rem 0;
.create-account-box {
border: 1px solid rgba(var(--palette-primary-500), 1);
border-radius: 0.25rem;
cursor: pointer;
font-size: 90%;

View File

@ -10,12 +10,14 @@ import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({
selector: 'gf-root',
@ -27,25 +29,29 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean;
public currentRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem;
public isLoggedIn = false;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.initializeTheme();
this.user = undefined;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
@ -59,31 +65,28 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.isLoggedIn = !!this.tokenStorageService.getToken();
.subscribe((state) => {
this.user = state.user;
if (this.isLoggedIn) {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.canCreateAccount = hasPermission(
this.user?.permissions,
permissions.createUserAccount
);
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
this.cd.markForCheck();
});
} else {
this.user = null;
}
this.changeDetectorRef.markForCheck();
});
}
public onCreateAccount() {
this.tokenStorageService.signOut();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload();
}

View File

@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import {
DateAdapter,
MAT_DATE_FORMATS,
@ -34,6 +35,7 @@ import { LanguageService } from './core/language.service';
HttpClientModule,
MarkdownModule.forRoot(),
MatButtonModule,
MatCardModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,

View File

@ -1,24 +1,13 @@
<table
class="gf-table w-100"
matSort
matSortActive="account"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Name
</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Platform
</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
@ -60,7 +49,7 @@
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
<td *matCellDef="let element" mat-cell>
{{ element.Order?.length }}
</td>

View File

@ -6,13 +6,9 @@ import {
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
@ -41,11 +35,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {}
public constructor() {}
public ngOnInit() {}
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.isLoading = false;
}

View File

@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule

View File

@ -8,9 +8,11 @@
class="d-none d-sm-block"
i18n
mat-flat-button
[color]="
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
"
[ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
@ -19,24 +21,27 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
[routerLink]="['/report']"
>X-ray</a
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools',
'text-decoration-underline':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'transactions',
'text-decoration-underline': currentRoute === 'transactions'
}"
[routerLink]="['/transactions']"
>Transactions</a
>
@ -44,7 +49,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'accounts' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']"
>Accounts</a
>
@ -53,7 +61,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']"
>Admin Control</a
>
@ -61,7 +72,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
@ -70,7 +84,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
@ -78,7 +95,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
@ -139,20 +159,18 @@
<hr class="m-0" />
</ng-container>
<a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[routerLink]="['/report']"
>X-ray</a
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
>
<a
class="d-block d-sm-none"
@ -231,28 +249,44 @@
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
<a
class="d-none d-sm-block mx-1"
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1 no-min-width px-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
>GitHub</a
>
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
Sign In
</button>
<a
class="d-none d-sm-block"
color="primary"
i18n
mat-flat-button
[routerLink]="['/register']"
>Get Started
</a>
</ng-container>
</mat-toolbar>

View File

@ -1,12 +1,14 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges
OnChanges,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@ -26,6 +28,8 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem;
@Input() user: User;
@Output() signOut = new EventEmitter<void>();
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
@ -75,8 +79,7 @@ export class HeaderComponent implements OnChanges {
}
public onSignOut() {
this.tokenStorageService.signOut();
window.location.reload();
this.signOut.next();
}
public openLoginDialog(): void {

View File

@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '../logo/logo.module';
import { HeaderComponent } from './header.component';

View File

@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
public constructor() {
Chart.register(
LinearScale,
LineController,
LineElement,
PointElement,
LinearScale,
TimeScale
);
}
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
responsive: true,
scales: {
x: {
display: false,
display: true,
grid: {
display: false
},

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'login-with-access-token-dialog',
selector: 'gf-login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html'

View File

@ -28,7 +28,7 @@ export class PerformanceChartDialog {
public title: string;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => {
this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
}
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.title = `Performance vs. ${this.benchmarkLabel}`;

View File

@ -34,7 +34,7 @@ export class PositionDetailDialog {
public transactionCount: number;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -127,7 +127,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems[0].value = this.averagePrice;
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
);
}

View File

@ -8,7 +8,7 @@
[removable]="true"
(removed)="removeKeyword(searchKeyword)"
>
{{ searchKeyword }}
{{ searchKeyword | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
@ -26,11 +26,8 @@
#autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)"
>
<mat-option
*ngFor="let transaction of filteredTransactions | async"
[value]="transaction"
>
{{ transaction }}
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter | gfSymbol }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
@ -178,9 +175,7 @@
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Account
</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon

View File

@ -57,10 +57,8 @@ export class TransactionsTableComponent
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = [];
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]);
public filteredTransactions: Observable<
string[]
> = this.filteredTransactions$.asObservable();
public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable();
public isLoading = true;
public placeholder = '';
public routeQueryParams: Subscription;
@ -68,7 +66,7 @@ export class TransactionsTableComponent
public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA];
private allFilteredTransactions: string[];
private allFilters: string[];
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -90,13 +88,13 @@ export class TransactionsTableComponent
this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filteredTransactions$.next(
this.allFilteredTransactions.filter(
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filteredTransactions$.next(this.allFilteredTransactions);
this.filters$.next(this.allFilters);
}
});
}
@ -239,13 +237,13 @@ export class TransactionsTableComponent
this.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
this.allFilteredTransactions = this.getSearchableFieldValues(
this.transactions
).filter((item) => {
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
});
this.allFilters = this.getSearchableFieldValues(this.transactions).filter(
(item) => {
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
}
);
this.filteredTransactions$.next(this.allFilteredTransactions);
this.filters$.next(this.allFilters);
}
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {

View File

@ -0,0 +1,10 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="h-100"
[theme]="{
width: '100%'
}"
></ngx-skeleton-loader>
<div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>

View File

@ -0,0 +1,32 @@
:host {
display: block;
height: 100%;
::ng-deep {
.loader {
height: 100% !important;
}
.svgMap-map-wrapper {
background: transparent;
.svgMap-country {
stroke: #e5e5e5;
}
.svgMap-map-controls-wrapper {
display: none;
}
}
}
}
:host-context(.is-dark-theme) {
::ng-deep {
.svgMap-map-wrapper {
.svgMap-country {
stroke: #414141;
}
}
}
}

View File

@ -0,0 +1,77 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
OnDestroy,
OnInit
} from '@angular/core';
import { Currency } from '@prisma/client';
import svgMap from 'svgmap';
@Component({
selector: 'gf-world-map-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './world-map-chart.component.html',
styleUrls: ['./world-map-chart.component.scss']
})
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: Currency;
@Input() countries: { [code: string]: { name: string; value: number } };
public isLoading = true;
public svgMapElement;
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnInit() {}
public ngOnChanges() {
if (this.countries) {
this.isLoading = true;
this.destroySvgMap();
this.initialize();
}
}
public ngOnDestroy() {
this.destroySvgMap();
}
private initialize() {
this.svgMapElement = new svgMap({
colorMax: '#22bdb9',
colorMin: '#c3f1f0',
colorNoData: 'transparent',
data: {
applyData: 'value',
data: {
value: {
format: `{0} ${this.baseCurrency}`
}
},
values: this.countries
},
hideFlag: true,
minZoom: 1.06,
maxZoom: 1.06,
targetElementID: 'svgMap'
});
setTimeout(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}, 500);
}
private destroySvgMap() {
this.svgMapElement?.mapWrapper?.remove();
this.svgMapElement?.tooltip?.remove();
this.svgMapElement = null;
}
}

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { WorldMapChartComponent } from './world-map-chart.component';
@NgModule({
declarations: [WorldMapChartComponent],
exports: [WorldMapChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfWorldMapChartModule {}

View File

@ -9,15 +9,22 @@ import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = [
'/about',
'/pricing',
'/register',
'/resources'
];
constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
@ -29,11 +36,14 @@ export class AuthGuard implements CanActivate {
}
return new Promise<boolean>((resolve) => {
this.dataService
.fetchUser()
this.userService
.get()
.pipe(
catchError(() => {
if (state.url !== '/start') {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {
this.router.navigate(['/start']);
resolve(false);
return EMPTY;
@ -45,12 +55,12 @@ export class AuthGuard implements CanActivate {
)
.subscribe((user) => {
if (
state.url === '/home' &&
state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN
) {
this.router.navigate(['/zen']);
resolve(false);
} else if (state.url === '/start') {
} else if (state.url.startsWith('/start')) {
if (user.settings.viewMode === ViewMode.ZEN) {
this.router.navigate(['/zen']);
} else {
@ -59,7 +69,7 @@ export class AuthGuard implements CanActivate {
resolve(false);
} else if (
state.url === '/zen' &&
state.url.startsWith('/zen') &&
user.settings.viewMode === ViewMode.DEFAULT
) {
this.router.navigate(['/home']);

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
const routes: Routes = [{ path: '', component: AboutPageComponent }];
const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -26,28 +25,23 @@ export class AboutPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {

View File

@ -5,15 +5,14 @@
<mat-card class="mb-3">
<mat-card-content>
<p>
<strong>Ghostfolio</strong> ({{ version }}) is open source software
which empowers busy folks to have a sharp look of their financial
assets and to make solid, data-driven investment decisions by
evaluating automated static portfolio analysis rules. The project
has been initiated by
<strong>Ghostfolio</strong> is open source software which empowers
busy folks to have a sharp look of their financial assets and to
make solid, data-driven investment decisions by evaluating automated
static portfolio analysis rules. The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>.
<ng-container *ngIf="lastPublish">
This instance has been last published on {{ lastPublish
}}.</ng-container
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.</ng-container
>
</p>
<p>

View File

@ -1,6 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -18,7 +18,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public baseCurrency: Currency;
public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public user: User;
@ -28,36 +27,30 @@ export class AccountPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, globalPermissions }) => {
.subscribe(({ currencies }) => {
this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -78,11 +71,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.userService.remove();
this.cd.markForCheck();
});
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
@ -98,7 +96,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => {
this.accesses = response;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -15,16 +15,21 @@
<div class="w-50" i18n>Alias</div>
<div class="w-50">{{ user.alias }}</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="d-flex py-1">
<div *ngIf="user?.subscription" class="d-flex py-1">
<div class="w-50" i18n>Membership</div>
<div class="w-50">
<div class="align-items-center d-flex mb-1">
{{ user?.subscription?.type }}
{{ user.subscription.type }}
</div>
<div>
<div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date:
defaultDateFormat }}
</div>
<div *ngIf="!user.subscription.expiresAt">
<button color="primary" disabled i18n mat-flat-button>
Upgrade
</button>
</div>
</div>
</div>
<div class="d-flex mt-4 py-1">

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
@ -17,6 +18,7 @@ import { AccountPageComponent } from './account-page.component';
CommonModule,
FormsModule,
GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatSelectModule,

View File

@ -5,7 +5,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client';
@ -35,14 +35,14 @@ export class AccountsPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class AccountsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.createAccount
);
this.hasPermissionToDeleteAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteAccount
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
this.fetchAccounts();
@ -105,7 +105,7 @@ export class AccountsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,10 +2,10 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
import { formatDistanceToNowStrict, isValid, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -32,9 +32,9 @@ export class AdminPageComponent implements OnInit {
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -43,13 +43,12 @@ export class AdminPageComponent implements OnInit {
public ngOnInit() {
this.fetchAdminData();
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
});
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
@ -77,14 +76,12 @@ export class AdminPageComponent implements OnInit {
public formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNow(
sub(parseISO(aDateString), { seconds: 10 }),
{
addSuffix: true
}
);
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
});
return distanceString === 'less than a minute ago'
return distanceString === 'in 0 seconds' ||
distanceString === '0 seconds ago'
? 'just now'
: distanceString;
}
@ -125,7 +122,7 @@ export class AdminPageComponent implements OnInit {
this.users = users;
if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNow(
this.lastDataGathering = formatDistanceToNowStrict(
new Date(lastDataGathering),
{
addSuffix: true
@ -140,7 +137,7 @@ export class AdminPageComponent implements OnInit {
this.transactionCount = transactionCount;
this.userCount = userCount;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
);
}

View File

@ -73,26 +73,40 @@
<table class="gf-table">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-center" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2" i18n>Registration Date</th>
<th class="mat-header-cell px-1 py-2" i18n>Accounts</th>
<th class="mat-header-cell px-1 py-2" i18n>Transactions</th>
<th class="mat-header-cell px-1 py-2" i18n>Engagement</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Registration Date
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Transactions
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Engagement
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let userItem of users" class="mat-row">
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }}
</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.createdAt | date: defaultDateFormat }}
</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Account }}</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Order }}</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Account }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Order }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.Analytics?.activityCount }}
</td>
<td class="mat-cell px-1 py-2">

View File

@ -10,7 +10,7 @@ import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioOverview,
PortfolioPerformance,
@ -58,7 +58,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@ -66,7 +66,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -76,14 +76,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
}
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
user.permissions,
this.user.permissions,
permissions.accessFearAndGreedIndex
);
@ -94,17 +94,17 @@ export class HomePageComponent implements OnDestroy, OnInit {
.subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -169,7 +169,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
};
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -178,14 +178,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.performance = response;
this.isLoadingPerformance = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -195,9 +195,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.hasPositions =
this.positions && Object.keys(this.positions).length > 0;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { LandingPageComponent } from './landing-page.component';
const routes: Routes = [
{ path: '', component: LandingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LandingPageRoutingModule {}

View File

@ -1,21 +1,17 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({
selector: 'gf-login-page',
templateUrl: './login-page.html',
styleUrls: ['./login-page.scss']
selector: 'gf-landing-page',
templateUrl: './landing-page.html',
styleUrls: ['./landing-page.scss']
})
export class LoginPageComponent implements OnDestroy, OnInit {
export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public historicalDataItems: LineChartItem[];
@ -26,9 +22,8 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private router: Router,
private tokenStorageService: TokenStorageService
) {}
@ -42,19 +37,10 @@ export class LoginPageComponent implements OnDestroy, OnInit {
this.initializeLineChart();
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken }) => {
this.openShowAccessTokenDialog(accessToken, authToken);
});
}
public initializeLineChart() {
this.historicalDataItems = [
{
@ -268,28 +254,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
];
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string
): void {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken
},
disableClose: true,
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);

View File

@ -13,16 +13,16 @@
class="align-items-center col d-flex justify-content-center position-relative"
>
<div class="py-5 text-center">
<button
<a
class="d-inline-block"
color="primary"
i18n
mat-flat-button
[disabled]="!demoAuthToken"
(click)="createAccount()"
[routerLink]="['/register']"
>
Create Account
</button>
Get Started
</a>
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
<button
class="d-inline-block"
@ -135,15 +135,15 @@
Join now or check out the example account
</p>
<div class="py-2 text-center">
<button
<a
color="primary"
i18n
mat-flat-button
[disabled]="!demoAuthToken"
(click)="createAccount()"
[routerLink]="['/register']"
>
Create Account
</button>
Get Started
</a>
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
<button
class="d-inline-block"

View File

@ -0,0 +1,25 @@
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 { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { LandingPageRoutingModule } from './landing-page-routing.module';
import { LandingPageComponent } from './landing-page.component';
@NgModule({
declarations: [LandingPageComponent],
exports: [],
imports: [
CommonModule,
GfLineChartModule,
GfLogoModule,
LandingPageRoutingModule,
MatButtonModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LandingPageModule {}

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PricingPageComponent } from './pricing-page.component';
const routes: Routes = [{ path: '', component: PricingPageComponent }];
const routes: Routes = [
{ path: '', component: PricingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -22,28 +21,23 @@ export class PricingPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {

View File

@ -1,96 +1,185 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3>
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Pricing Plans
</h3>
<mat-card class="mb-4">
<mat-card-content>
<p>
Our official
<strong>Ghostfolio</strong> cloud offering is the easiest way to get
started. Due to the time it saves, this will be the best option for
most people. The revenue is used for covering the hosting costs.
</p>
<p>
If you prefer to run <strong>Ghostfolio</strong> on your own
infrastructure, please find the source code and further instructions
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</mat-card-content>
</mat-card>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Open Source</h4>
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
<p class="h5 text-right">
<span>Free</span>
</p>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1">
<h4 i18n>Open Source</h4>
<p>
For tech-savvy investors who prefer to run
<strong>Ghostfolio</strong> on their own infrastructure.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Self-hosted.</p>
<p class="h5 text-right">Free</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<div class="col-xs-12 col-md-4 mb-3">
<mat-card
class="mb-3"
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }"
class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Basic' }"
>
<h4 class="align-items-center d-flex" i18n>
Diamond
<ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</h4>
<p>
Get a fully managed <strong>Ghostfolio</strong> cloud offering.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>Basic</h4>
<p>
For new investors who are just getting started with trading.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right">Free</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card
class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Premium' }"
>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>
Premium
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</h4>
<p>
For ambitious investors who need the full picture of their
financial assets.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right">
<span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }}
<strong>2.99</strong>
<strong>0.00</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span
>
</p>
@ -99,4 +188,12 @@
</div>
</div>
</div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
Get Started
</a>
<p class="text-muted"><small>It's free</small></p>
</div>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { PricingPageRoutingModule } from './pricing-page-routing.module';
import { PricingPageComponent } from './pricing-page.component';
@ -8,7 +10,13 @@ import { PricingPageComponent } from './pricing-page.component';
@NgModule({
declarations: [PricingPageComponent],
exports: [],
imports: [CommonModule, MatCardModule, PricingPageRoutingModule],
imports: [
CommonModule,
MatButtonModule,
MatCardModule,
PricingPageRoutingModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -2,6 +2,15 @@
color: rgb(var(--dark-primary-text));
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: bold;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
.mat-card {
&.active {
border-color: rgba(var(--palette-primary-500), 1);
@ -11,4 +20,8 @@
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
a {
color: rgb(var(--light-primary-text));
}
}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { RegisterPageComponent } from './register-page.component';
const routes: Routes = [
{ path: '', component: RegisterPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RegisterPageRoutingModule {}

View File

@ -0,0 +1,98 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({
selector: 'gf-register-page',
templateUrl: './register-page.html',
styleUrls: ['./register-page.scss']
})
export class RegisterPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public hasPermissionForSocialLogin: boolean;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private router: Router,
private tokenStorageService: TokenStorageService
) {
this.tokenStorageService.signOut();
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchInfo()
.subscribe(({ demoAuthToken, globalPermissions }) => {
this.demoAuthToken = demoAuthToken;
this.hasPermissionForSocialLogin = hasPermission(
globalPermissions,
permissions.enableSocialLogin
);
this.changeDetectorRef.markForCheck();
});
}
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken }) => {
this.openShowAccessTokenDialog(accessToken, authToken);
});
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string
): void {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken
},
disableClose: true,
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,30 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Create your Ghostfolio account
</h3>
<mat-card class="mb-4">
<mat-card-content class="text-center">
<button
class="d-inline-block"
color="primary"
i18n
mat-flat-button
[disabled]="!demoAuthToken"
(click)="createAccount()"
>
Create Account
</button>
<ng-container *ngIf="hasPermissionForSocialLogin">
<div class="my-3 text-muted" i18n>or</div>
<a color="accent" href="/api/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Continue with Google</span></a
>
</ng-container>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -1,27 +1,27 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { LoginPageRoutingModule } from './login-page-routing.module';
import { LoginPageComponent } from './login-page.component';
import { RegisterPageRoutingModule } from './register-page-routing.module';
import { RegisterPageComponent } from './register-page.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
@NgModule({
declarations: [LoginPageComponent],
declarations: [RegisterPageComponent],
exports: [],
imports: [
CommonModule,
GfLineChartModule,
GfLogoModule,
LoginPageRoutingModule,
MatButtonModule,
MatCardModule,
RegisterPageRoutingModule,
RouterModule,
ShowAccessTokenDialogModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LoginPageModule {}
export class RegisterPageModule {}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -7,7 +7,7 @@ import {
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'show-access-token-dialog',
selector: 'gf-show-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./show-access-token-dialog.scss'],
templateUrl: 'show-access-token-dialog.html'
@ -16,7 +16,7 @@ export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
@ -26,7 +26,7 @@ export class ShowAccessTokenDialog {
setTimeout(() => {
this.isAgreeButtonDisabled = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}, 1500);
}
}

View File

@ -1,5 +1,5 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ResourcesPageComponent } from './resources-page.component';
const routes: Routes = [{ path: '', component: ResourcesPageComponent }];
const routes: Routes = [
{ path: '', component: ResourcesPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -2,7 +2,8 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
PortfolioItem,
PortfolioPosition,
@ -21,6 +22,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public period = 'current';
public periodOptions: ToggleOption[] = [
@ -40,11 +47,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -66,7 +73,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe((response) => {
this.portfolioItems = response;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -76,18 +83,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.portfolioPositions = response;
this.initializeAnalysisData(this.portfolioPositions, this.period);
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -98,6 +104,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
aPeriod: string
) {
this.accounts = {};
this.continents = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.countries = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.positions = {};
this.positionsArray = [];
@ -123,11 +141,53 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
aPeriod === 'original' ? original : current;
} else {
this.accounts[account] = {
value: aPeriod === 'original' ? original : current,
name: account
name: account,
value: aPeriod === 'original' ? original : current
};
}
}
if (position.countries.length > 0) {
for (const country of position.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
this.continents[continent].value += weight * position.value;
} else {
this.continents[continent] = {
name: continent,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
} else {
this.countries[code] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
this.countries[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
}
}

View File

@ -146,6 +146,50 @@
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Continent</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[positions]="continents"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Country</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[positions]="countries"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="d-block d-sm-none row">
<div class="col-lg">
@ -167,12 +211,33 @@
</mat-card>
</div>
</div>
<div *ngIf="!hasImpersonationId" class="d-none d-sm-block row">
<div class="row world-map-chart">
<div class="col-lg">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>Regions</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-world-map-chart
[baseCurrency]="user?.settings?.baseCurrency"
[countries]="countries"
></gf-world-map-chart>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col-lg">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Investment</mat-card-title
>Timeline</mat-card-title
>
</mat-card-header>
<mat-card-content>

View File

@ -6,6 +6,7 @@ import { PortfolioPositionsChartModule } from '@ghostfolio/client/components/por
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component';
@ -19,6 +20,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
GfInvestmentChartModule,
GfPositionsTableModule,
GfToggleModule,
GfWorldMapChartModule,
MatCardModule,
PortfolioPositionsChartModule,
PortfolioProportionChartModule

View File

@ -7,6 +7,14 @@
}
}
.world-map-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
}
}
}
.mat-card {
.mat-card-header {
::ng-deep {

View File

@ -20,7 +20,7 @@ export class ReportPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
@ -38,7 +38,7 @@ export class ReportPageComponent implements OnInit {
portfolioReport.rules['currencyClusterRisk'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { LoginPageComponent } from './login-page.component';
import { ToolsPageComponent } from './tools-page.component';
const routes: Routes = [
{ path: '', component: LoginPageComponent, canActivate: [AuthGuard] }
{ path: '', component: ToolsPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginPageRoutingModule {}
export class ToolsPageRoutingModule {}

View File

@ -0,0 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'gf-tools-page',
templateUrl: './tools-page.html',
styleUrls: ['./tools-page.scss']
})
export class ToolsPageComponent implements OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
/**
* Initializes the controller
*/
public ngOnInit() {}
}

View File

@ -0,0 +1,43 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Tools</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Analysis</h4>
<p class="mb-0">
Ghostfolio Analysis shows your positions and visualizes your
portfolio.
</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/tools', 'analysis']"
>
Open Analysis →
</a>
</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>X-ray</h4>
<p class="mb-0">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/tools', 'report']"
>
Open X-ray →
</a>
</p>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { ToolsPageRoutingModule } from './tools-page-routing.module';
import { ToolsPageComponent } from './tools-page.component';
@NgModule({
declarations: [ToolsPageComponent],
exports: [],
imports: [
CommonModule,
MatButtonModule,
MatCardModule,
RouterModule,
ToolsPageRoutingModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ToolsPageModule {}

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -42,7 +42,7 @@ export class CreateOrUpdateTransactionDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
@ -73,7 +73,7 @@ export class CreateOrUpdateTransactionDialog {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
}
@ -100,7 +100,7 @@ export class CreateOrUpdateTransactionDialog {
this.isLoading = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -5,7 +5,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
@ -35,14 +35,14 @@ export class TransactionsPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class TransactionsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToDeleteOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteOrder
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
this.fetchOrders();
@ -105,7 +105,7 @@ export class TransactionsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -31,26 +31,25 @@ export class ZenPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -87,7 +86,7 @@ export class ZenPageComponent implements OnDestroy, OnInit {
};
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -96,9 +95,9 @@ export class ZenPageComponent implements OnDestroy, OnInit {
this.performance = response;
this.isLoadingPerformance = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
}

View File

@ -74,13 +74,6 @@ export class DataService {
}
public fetchInfo() {
/*
if (this.info) {
// TODO: Cache info
return of(this.info);
}
*/
return this.http.get<InfoItem>('/api/info').pipe(
map((data) => {
if (
@ -154,10 +147,6 @@ export class DataService {
);
}
public fetchUser() {
return this.http.get<User>('/api/user');
}
public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { UserService } from './user/user.service';
const TOKEN_KEY = 'auth-token';
@ -7,23 +8,15 @@ const TOKEN_KEY = 'auth-token';
providedIn: 'root'
})
export class TokenStorageService {
private hasTokenChangeSubject = new BehaviorSubject<void>(null);
public constructor() {}
public constructor(private userService: UserService) {}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
}
public onChangeHasToken() {
return this.hasTokenChangeSubject.asObservable();
}
public saveToken(token: string): void {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.setItem(TOKEN_KEY, token);
this.hasTokenChangeSubject.next();
}
public signOut(): void {
@ -31,10 +24,10 @@ export class TokenStorageService {
window.localStorage.clear();
this.userService.remove();
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
}
this.hasTokenChangeSubject.next();
}
}

View File

@ -0,0 +1,4 @@
export enum UserStoreActions {
GetUser = 'GET_USER',
RemoveUser = 'REMOVE_USER'
}

View File

@ -0,0 +1,5 @@
import { User } from '@ghostfolio/common/interfaces';
export interface UserStoreState {
user: User;
}

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