Compare commits

..

13 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
59 changed files with 1080 additions and 177 deletions

View File

@ -5,6 +5,42 @@ 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

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

@ -20,6 +20,7 @@ import {
getYear,
isAfter,
isSameDay,
parse,
parseISO,
setDate,
setMonth,
@ -75,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 }
@ -215,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',
@ -227,6 +231,7 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice;
let minPrice = marketPrice;
@ -234,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
});

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
)
};
});
@ -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

@ -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;
}

View File

@ -45,6 +45,13 @@ const routes: Routes = [
(m) => m.PricingPageModule
)
},
{
path: 'register',
loadChildren: () =>
import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule
)
},
{
path: 'resources',
loadChildren: () =>
@ -55,7 +62,9 @@ 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',

View File

@ -12,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>

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

@ -68,17 +68,12 @@ export class AppComponent implements OnDestroy, OnInit {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.user = state.user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
} else if (!this.tokenStorageService.getToken()) {
// User has not been logged in
this.user = null;
}
this.canCreateAccount = hasPermission(
this.user?.permissions,
permissions.createUserAccount
);
this.changeDetectorRef.markForCheck();
});
@ -86,7 +81,6 @@ export class AppComponent implements OnDestroy, OnInit {
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public onSignOut() {

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

@ -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,13 +21,16 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
? 'primary'
: null
"
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools',
'text-decoration-underline':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
>
@ -33,7 +38,10 @@
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
>
@ -41,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
>
@ -50,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
>
@ -58,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
>
@ -67,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
>
@ -75,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
>
@ -226,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

@ -8,7 +8,7 @@ import {
} 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';

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

@ -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

@ -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

@ -14,7 +14,12 @@ import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
private static PUBLIC_PAGE_ROUTES = [
'/about',
'/pricing',
'/register',
'/resources'
];
constructor(
private router: Router,

View File

@ -80,7 +80,10 @@ export class AdminPageComponent implements OnInit {
addSuffix: true
});
return distanceString === '0 seconds ago' ? 'just now' : distanceString;
return distanceString === 'in 0 seconds' ||
distanceString === '0 seconds ago'
? 'just now'
: distanceString;
}
return '';

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 { LandingPageComponent } from './landing-page.component';
const routes: Routes = [
{ path: '', component: LoginPageComponent, canActivate: [AuthGuard] }
{ path: '', component: LandingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginPageRoutingModule {}
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[];
@ -28,7 +24,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private router: Router,
private tokenStorageService: TokenStorageService
) {}
@ -46,15 +41,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
});
}
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,7 +1,9 @@
<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>
@ -188,8 +190,8 @@
</div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/start']">
Create Account
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
Get Started
</a>
<p class="text-muted"><small>It's free</small></p>
</div>

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'

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

@ -3,6 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-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[] = [
@ -97,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 = [];
@ -122,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 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

@ -9,14 +9,14 @@
portfolio.
</p>
<p class="text-right">
<button
<a
color="primary"
i18n
mat-button
[routerLink]="['/tools', 'analysis']"
>
Open Analysis →
</button>
</a>
</p>
</mat-card>
</div>
@ -28,14 +28,14 @@
risks in your portfolio.
</p>
<p class="text-right">
<button
<a
color="primary"
i18n
mat-button
[routerLink]="['/tools', 'report']"
>
Open X-ray →
</button>
</a>
</p>
</mat-card>
</div>

View File

@ -1,12 +1,14 @@
import { Injectable } from '@angular/core';
import { UserService } from './user/user.service';
const TOKEN_KEY = 'auth-token';
@Injectable({
providedIn: 'root'
})
export class TokenStorageService {
public constructor() {}
public constructor(private userService: UserService) {}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
@ -22,6 +24,8 @@ export class TokenStorageService {
window.localStorage.clear();
this.userService.remove();
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
}

View File

@ -6,18 +6,22 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io</loc>
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about</loc>
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pricing</loc>
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/register</loc>
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/resources</loc>
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
</url>
</urlset>

View File

@ -4,6 +4,8 @@
@import '~angular-material-css-vars/main';
@import '~svgmap/dist/svgMap';
$mat-css-dark-theme-selector: '.is-dark-theme';
$mat-css-light-theme-selector: '.is-light-theme';
@ -79,6 +81,14 @@ body {
color: rgba(var(--dark-primary-text)) !important;
}
}
.svgMap-tooltip {
background: var(--dark-background);
.svgMap-tooltip-content table td span {
color: rgba(var(--light-primary-text));
}
}
}
}
@ -153,3 +163,15 @@ ngx-skeleton-loader {
.no-min-width {
min-width: unset !important;
}
.svgMap-tooltip {
border-bottom: none;
.svgMap-tooltip-pointer {
display: none;
}
}
.text-decoration-underline {
text-decoration: underline !important;
}

View File

@ -0,0 +1,6 @@
export interface Country {
code: string;
continent: string;
name: string;
weight: number;
}

View File

@ -1,12 +1,15 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client';
import { Country } from './country.interface';
export interface PortfolioPosition {
accounts: {
[name: string]: { current: number; original: number };
};
allocationCurrent: number;
allocationInvestment: number;
countries: Country[];
currency: Currency;
exchange?: string;
grossPerformance: number;
@ -24,4 +27,5 @@ export interface PortfolioPosition {
symbol: string;
type?: string;
url?: string;
value: number;
}

View File

@ -1,5 +1,8 @@
import { Account, Order, Platform } from '@prisma/client';
import { Account, Order, Platform, SymbolProfile } from '@prisma/client';
type AccountWithPlatform = Account & { Platform?: Platform };
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
export type OrderWithAccount = Order & {
Account?: AccountWithPlatform;
SymbolProfile?: SymbolProfile;
};

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.10.1",
"version": "1.14.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -18,7 +18,7 @@
"database:format-schema": "prisma format",
"database:generate-typings": "prisma generate",
"database:gui": "prisma studio",
"database:push": "prisma db push --preview-feature",
"database:push": "prisma db push",
"database:seed": "prisma db seed --preview-feature",
"dep-graph": "nx dep-graph",
"e2e": "ng e2e",
@ -65,7 +65,7 @@
"@nestjs/schedule": "0.4.1",
"@nestjs/serve-static": "2.1.4",
"@nrwl/angular": "12.0.0",
"@prisma/client": "2.20.1",
"@prisma/client": "2.24.1",
"@types/lodash": "4.14.168",
"alphavantage": "2.2.0",
"angular-material-css-vars": "1.1.2",
@ -79,6 +79,7 @@
"cheerio": "1.0.0-rc.6",
"class-transformer": "0.3.2",
"class-validator": "0.13.1",
"countries-list": "2.6.1",
"countup.js": "2.0.7",
"cryptocurrencies": "7.0.0",
"date-fns": "2.19.0",
@ -92,10 +93,11 @@
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "2.20.1",
"prisma": "2.24.1",
"reflect-metadata": "0.1.13",
"round-to": "5.0.0",
"rxjs": "6.6.7",
"svgmap": "2.1.1",
"uuid": "8.3.2",
"yahoo-finance": "0.3.6",
"zone.js": "0.11.4"

View File

@ -0,0 +1,171 @@
-- CreateEnum
CREATE TYPE "AccountType" AS ENUM ('SECURITIES');
-- CreateEnum
CREATE TYPE "Currency" AS ENUM ('CHF', 'EUR', 'USD', 'GBP');
-- CreateEnum
CREATE TYPE "DataSource" AS ENUM ('GHOSTFOLIO', 'RAKUTEN', 'YAHOO', 'ALPHA_VANTAGE');
-- CreateEnum
CREATE TYPE "ViewMode" AS ENUM ('DEFAULT', 'ZEN');
-- CreateEnum
CREATE TYPE "Provider" AS ENUM ('GOOGLE', 'ANONYMOUS');
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'DEMO');
-- CreateEnum
CREATE TYPE "Type" AS ENUM ('BUY', 'SELL');
-- CreateTable
CREATE TABLE "Access" (
"granteeUserId" TEXT NOT NULL,
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
PRIMARY KEY ("id","userId")
);
-- CreateTable
CREATE TABLE "Account" (
"accountType" "AccountType" NOT NULL DEFAULT E'SECURITIES',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"id" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT,
"platformId" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
PRIMARY KEY ("id","userId")
);
-- CreateTable
CREATE TABLE "Analytics" (
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"activityCount" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "MarketData" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date" TIMESTAMP(3) NOT NULL,
"id" TEXT NOT NULL,
"symbol" TEXT NOT NULL,
"marketPrice" DOUBLE PRECISION NOT NULL
);
-- CreateTable
CREATE TABLE "Order" (
"currency" "Currency" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"fee" DOUBLE PRECISION NOT NULL,
"id" TEXT NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"symbol" TEXT NOT NULL,
"type" "Type" NOT NULL,
"unitPrice" DOUBLE PRECISION NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"accountId" TEXT,
"accountUserId" TEXT,
"dataSource" "DataSource" NOT NULL DEFAULT E'YAHOO',
PRIMARY KEY ("id","userId")
);
-- CreateTable
CREATE TABLE "Platform" (
"id" TEXT NOT NULL,
"name" TEXT,
"url" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Property" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "Settings" (
"currency" "Currency",
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"viewMode" "ViewMode",
PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "Subscription" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"id" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
PRIMARY KEY ("id","userId")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"provider" "Provider",
"thirdPartyId" TEXT,
"accessToken" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"role" "Role" NOT NULL DEFAULT E'USER',
"updatedAt" TIMESTAMP(3) NOT NULL,
"alias" TEXT,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MarketData.date_symbol_unique" ON "MarketData"("date", "symbol");
-- CreateIndex
CREATE INDEX "MarketData.symbol_index" ON "MarketData"("symbol");
-- CreateIndex
CREATE UNIQUE INDEX "Platform.url_unique" ON "Platform"("url");
-- AddForeignKey
ALTER TABLE "Access" ADD FOREIGN KEY ("granteeUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Access" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD FOREIGN KEY ("platformId") REFERENCES "Platform"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analytics" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD FOREIGN KEY ("accountId", "accountUserId") REFERENCES "Account"("id", "userId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settings" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "symbolProfileId" TEXT;
-- CreateTable
CREATE TABLE "SymbolProfile" (
"countries" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dataSource" "DataSource" NOT NULL,
"id" TEXT NOT NULL,
"name" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"symbol" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SymbolProfile.dataSource_symbol_unique" ON "SymbolProfile"("dataSource", "symbol");
-- AddForeignKey
ALTER TABLE "Order" ADD FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -59,22 +59,24 @@ model MarketData {
}
model Order {
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
createdAt DateTime @default(now())
currency Currency
dataSource DataSource @default(YAHOO)
date DateTime
fee Float
id String @default(uuid())
quantity Float
symbol String
type Type
unitPrice Float
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
createdAt DateTime @default(now())
currency Currency
dataSource DataSource @default(YAHOO)
date DateTime
fee Float
id String @default(uuid())
quantity Float
symbol String
SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String?
type Type
unitPrice Float
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id])
userId String
@@id([id, userId])
}
@ -99,6 +101,19 @@ model Settings {
userId String @id
}
model SymbolProfile {
countries Json?
createdAt DateTime @default(now())
dataSource DataSource
id String @id @default(uuid())
name String?
Order Order[]
updatedAt DateTime @updatedAt
symbol String
@@unique([dataSource, symbol])
}
model Subscription {
createdAt DateTime @default(now())
expiresAt DateTime

View File

@ -1,6 +1,7 @@
import {
AccountType,
Currency,
DataSource,
PrismaClient,
Role,
Type
@ -135,17 +136,53 @@ async function main() {
where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' }
});
await prisma.symbolProfile.createMany({
data: [
{
countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO,
id: '2bd26362-136e-411c-b578-334084b4cdcc',
symbol: 'AMZN'
},
{
countries: null,
dataSource: DataSource.YAHOO,
id: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
symbol: 'BTCUSD'
},
{
countries: [{ code: 'US', weight: 1 }],
dataSource: DataSource.YAHOO,
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
symbol: 'TSLA'
},
{
countries: [
{ code: 'US', weight: 0.9886789999999981 },
{ code: 'NL', weight: 0.000203 },
{ code: 'CA', weight: 0.000362 }
],
dataSource: DataSource.YAHOO,
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbol: 'VTI'
}
],
skipDuplicates: true
});
await prisma.order.createMany({
data: [
{
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
fee: 30,
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
quantity: 50,
symbol: 'TSLA',
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
type: Type.BUY,
unitPrice: 42.97,
userId: userDemo.id
@ -154,11 +191,13 @@ async function main() {
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
fee: 29.9,
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
quantity: 0.5614682,
symbol: 'BTCUSD',
symbolProfileId: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
type: Type.BUY,
unitPrice: 3562.089535970158,
userId: userDemo.id
@ -167,11 +206,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
fee: 80.79,
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
quantity: 5,
symbol: 'AMZN',
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc',
type: Type.BUY,
unitPrice: 2021.99,
userId: userDemo.id
@ -180,11 +221,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
fee: 19.9,
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 144.38,
userId: userDemo.id
@ -193,11 +236,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
fee: 19.9,
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 147.99,
userId: userDemo.id
@ -206,11 +251,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
fee: 19.9,
id: '347b0430-a84f-4031-a0f9-390399066ad6',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 151.41,
userId: userDemo.id
@ -219,11 +266,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
fee: 19.9,
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 177.69,
userId: userDemo.id
@ -232,11 +281,13 @@ async function main() {
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
fee: 19.9,
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
type: Type.BUY,
unitPrice: 203.15,
userId: userDemo.id

View File

@ -2081,22 +2081,22 @@
consola "^2.15.0"
node-fetch "^2.6.1"
"@prisma/client@2.20.1":
version "2.20.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.20.1.tgz#45e7bade3a1b58972fc35e511578e313ead18770"
integrity sha512-/IYPubBi55rNMHfE0wwglA6eTWEZD77oz+x+3Mm9ji2lDKdS1lnYKZ0wZX0E3AB8gTNL/zsGtfzmfjgn3ePyIw==
"@prisma/client@2.24.1":
version "2.24.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.24.1.tgz#c4f26fb4d768dd52dd20a17e626f10e69cc0b85c"
integrity sha512-vllhf36g3oI98GF1Q5IPmnR5MYzBPeCcl/Xiz6EAi4DMOxE069o9ka5BAqYbUG2USx8JuKw09QdMnDrp3Kyn8g==
dependencies:
"@prisma/engines-version" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
"@prisma/engines-version" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
"@prisma/engines-version@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#1189f0a7e682f500015446cfe2b34d2753452190"
integrity sha512-fJhbGZXm2SPs/RsI79Ew4SFe+6QmChNdgU2I/SIjmU18bUgK8f1TBEWnVtFdBqEDHYPGxbpaianF7lp04KN7EA==
"@prisma/engines-version@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#2c5813ef98bcbe659b18b521f002f5c8aabbaae2"
integrity sha512-60Do+ByVfHnhJ2id5h/lXOZnDQNIf5pz3enkKWOmyr744Z2IxkBu65jRckFfMN5cPtmXDre/Ay/GKm0aoeLwrw==
"@prisma/engines@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#18f23c4a8a335a93fd338f88c310f5cf120f5591"
integrity sha512-zOWETm7DTRvlwf/CekPNSeJe6EC5bn2IFexd74wM9zgBXCZo+1sMDuNGtCqIt4Rzv8CcimEgyzrEFVq0LPV8qg==
"@prisma/engines@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#7e542d510f0c03f41b73edbb17254f5a0b272a4d"
integrity sha512-29/xO9kqeQka+wN5Ev10l5L4XQXNVXdPToJs1M29VZ2imQsNsL4rtz26m3qGM54IoGWwwfTVdvuVRxKnDl2rig==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1"
@ -4698,6 +4698,11 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"
countries-list@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb"
integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ==
countup.js@2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"
@ -10702,12 +10707,12 @@ pretty-format@26.x, pretty-format@^26.0.0, pretty-format@^26.6.2:
ansi-styles "^4.0.0"
react-is "^17.0.1"
prisma@2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.20.1.tgz#28c52135523e0853258cd4ca7e883e9e4b5a9d40"
integrity sha512-zyPvJSUfJrmciP2D/4aUrsyIefiH8AIJUeuq1a0X1df1AFw9QQ+ata/7VQdoP+RIQHnCb6Kln9kqfUw/fieljw==
prisma@2.24.1:
version "2.24.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.24.1.tgz#f8f4cb8baf407a71800256160277f69603bd43a3"
integrity sha512-L+ykMpttbWzpTNsy+PPynnEX/mS1s5zs49euXBrMjxXh1M6/f9MYlTNAj+iP90O9ZSaURSpNa+1jzatPghqUcQ==
dependencies:
"@prisma/engines" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
"@prisma/engines" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
prismjs@^1.23.0:
version "1.23.0"
@ -12377,6 +12382,18 @@ supports-hyperlinks@^2.0.0:
has-flag "^4.0.0"
supports-color "^7.0.0"
svg-pan-zoom@^3.6.1:
version "3.6.1"
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==
svgmap@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/svgmap/-/svgmap-2.1.1.tgz#355c259cf4e04b20d2d39bab05d0e718ade942ff"
integrity sha512-1blZYMYDXq8H3xykzgBJRh5q+XPd5JLOJ8K7UuZI6ab2D3hngiVcr+Z1olfy7DH9Xf9AOCTpt4Id7iVD8cKD0A==
dependencies:
svg-pan-zoom "^3.6.1"
svgo@^1.0.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"