Compare commits

...

27 Commits

Author SHA1 Message Date
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
84 changed files with 1722 additions and 1075 deletions

3
.gitignore vendored
View File

@ -24,15 +24,16 @@
# misc
/.angular/cache
.env.prod
/.sass-cache
/connect.lock
/coverage
/dist
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store

View File

@ -5,6 +5,83 @@ 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.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added

View File

@ -1,6 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -24,7 +23,6 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -174,7 +172,6 @@ export class AdminService {
_count: {
select: { Account: true, Order: true }
},
alias: true,
Analytics: {
select: {
activityCount: true,
@ -194,7 +191,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
@ -206,7 +203,6 @@ export class AdminService {
: undefined;
return {
alias,
createdAt,
engagement,
id,

View File

@ -1,5 +1,6 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
Body,
Controller,
@ -31,7 +32,9 @@ export class AuthController {
) {}
@Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
@ -65,6 +68,23 @@ export class AuthController {
}
}
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'))
public async generateRegistrationOptions() {

View File

@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,7 +14,7 @@ export class AuthService {
private readonly userService: UserService
) {}
public async validateAnonymousLogin(accessToken: string) {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +27,7 @@ export class AuthService {
});
if (user) {
const jwt: string = this.jwtService.sign({
const jwt = this.jwtService.sign({
id: user.id
});
@ -40,6 +41,33 @@ export class AuthService {
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({
provider,
thirdPartyId
@ -57,13 +85,14 @@ export class AuthService {
});
}
const jwt: string = this.jwtService.sign({
return this.jwtService.sign({
id: user.id
});
return jwt;
} catch (err) {
throw new InternalServerErrorException('validateOAuthLogin', err.message);
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
);
}
}
}

View File

@ -34,8 +34,20 @@ export class ImportController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try {
return await this.importService.import({
maxActivitiesToImport,
activities: importData.activities,
userId: this.request.user.id
});

View File

@ -17,9 +17,11 @@ export class ImportService {
public async import({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}): Promise<void> {
for (const activity of activities) {
@ -32,7 +34,11 @@ export class ImportService {
}
}
await this.validateActivities({ activities, userId });
await this.validateActivities({
activities,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
@ -81,19 +87,15 @@ export class ImportService {
private async validateActivities({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
if (activities?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const existingActivities = await this.orderService.orders({

View File

@ -1,5 +1,12 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
@ -39,6 +46,10 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true })
type: Type;

View File

@ -16,6 +16,7 @@ import {
DataSource,
Order,
Prisma,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
@ -71,6 +72,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
userId: string;
}
): Promise<Order> {
@ -80,6 +82,8 @@ export class OrderService {
return account.isDefault === true;
});
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
@ -142,6 +146,7 @@ export class OrderService {
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -150,7 +155,12 @@ export class OrderService {
data: {
...orderData,
Account,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}
});
}
@ -298,6 +308,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
};
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
@ -305,6 +316,8 @@ export class OrderService {
delete data.Account;
}
const tags = data.tags ?? [];
let isDraft = false;
if (data.type === 'ITEM') {
@ -331,11 +344,17 @@ export class OrderService {
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
return this.prismaService.order.update({
data: {
...data,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
},
where
});

View File

@ -1,5 +1,12 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
@ -41,6 +48,10 @@ export class UpdateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString()
type: Type;

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
});
});
});

View File

@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('273.2')
});
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
});
});
});

View File

@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('75.80')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
});
});
});

View File

@ -14,8 +14,11 @@ import {
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
max,
min
min,
set
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
@ -323,6 +326,53 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
}
return investments;
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string

View File

@ -20,7 +20,12 @@ import {
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -217,7 +222,8 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
@Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -229,9 +235,16 @@ export class PortfolioController {
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if (
impersonationId ||

View File

@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type {
AccountWithValue,
DateRange,
GroupBy,
Market,
OrderWithAccount,
RequestWithUser
@ -64,6 +65,7 @@ import {
max,
parse,
parseISO,
set,
setDayOfYear,
startOfDay,
subDays,
@ -183,7 +185,8 @@ export class PortfolioService {
}
public async getInvestments(
aImpersonationId: string
aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -204,28 +207,57 @@ export class PortfolioService {
return [];
}
const investments = portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
let investments: InvestmentItem[];
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
if (groupBy === 'month') {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
// Add investment of current month
const dateOfCurrentMonth = format(
set(new Date(), { date: 1 }),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
});
if (investmentOfCurrentMonth.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
}
return sortBy(investments, (investment) => {

View File

@ -36,14 +36,7 @@ export class UserService {
}
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
@ -63,7 +56,6 @@ export class UserService {
}
return {
alias,
id,
permissions,
subscription,

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
{
"LUNA1": "Terra",
"UNI1": "Uniswap"
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -33,8 +33,8 @@ export class ConfigurationService {
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),

View File

@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
@ -261,6 +266,16 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');

View File

@ -122,15 +122,6 @@ export class ExchangeRateDataService {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1;
if (aFromCurrency !== aToCurrency) {

View File

@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string;

View File

@ -78,6 +78,20 @@ const routes: Routes = [
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'en/blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'en/blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'features',
loadChildren: () =>

View File

@ -1,3 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -29,11 +29,10 @@
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>{{ userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>{{ (userItem.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"

View File

@ -124,13 +124,11 @@
: 'radio-button-on-outline'
"
></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
disabled="false"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>

View File

@ -2,26 +2,29 @@
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div class="row w-100">
<div class="chart-container col">
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>

View File

@ -5,7 +5,8 @@
.chart-container {
aspect-ratio: 16 / 9;
max-height: 50vh;
height: auto;
max-width: 67rem;
// Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) {

View File

@ -1,3 +1,7 @@
:host {
display: block;
ngx-skeleton-loader {
height: 100%;
}
}

View File

@ -22,7 +22,10 @@ import {
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types';
import {
BarController,
BarElement,
Chart,
LineController,
LineElement,
@ -42,6 +45,7 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@ -53,6 +57,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
BarController,
BarElement,
LinearScale,
LineController,
LineElement,
@ -78,7 +84,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
this.isLoading = true;
if (this.investments?.length > 0) {
if (!this.groupBy && this.investments?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.investments[0];
this.investments.unshift({
@ -102,13 +108,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
const data = {
labels: this.investments.map((position) => {
return position.date;
labels: this.investments.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => {
return position.investment;
}),
@ -137,6 +144,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
@ -178,8 +186,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return transformTickToAbbreviation(value);
@ -192,12 +202,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
type: this.groupBy ? 'bar' : 'line'
});
this.isLoading = false;
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {

View File

@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
@ -16,7 +19,10 @@ export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
private internetIdentityService: InternetIdentityService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {}
ngOnInit() {}
@ -31,4 +37,14 @@ export class LoginWithAccessTokenDialog {
public onClose() {
this.dialogRef.close();
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.dialogRef.close();
this.router.navigate(['/']);
} catch {}
}
}

View File

@ -5,16 +5,7 @@
></gf-dialog-header>
<div mat-dialog-content>
<div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center">
<a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a
>
</div>
<div class="my-3 text-center text-muted" i18n>or</div>
</ng-container>
<div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Security Token</mat-label>
<textarea
@ -24,6 +15,29 @@
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">
<button
class="mb-2"
mat-stroked-button
(click)="onLoginWithInternetIdentity()"
>
<img
class="mr-2"
src="./assets/icons/internet-computer.svg"
style="height: 0.75rem"
/><span i18n>Sign in with Internet Identity</span>
</button>
<a href="/api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="./assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Sign in with Google</span></a
>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions>

View File

@ -12,4 +12,12 @@
}
}
}
.mat-form-field {
::ng-deep {
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
}
}

View File

@ -142,4 +142,15 @@
</button>
</div>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

View File

@ -27,6 +27,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToShowValues = true;
@Input() locale: string;
@Input() pageSize = Number.MAX_SAFE_INTEGER;

View File

@ -8,10 +8,6 @@
<div class="col">
<mat-card class="mb-3">
<mat-card-content>
<div *ngIf="user.alias" class="d-flex py-1">
<div class="pr-1 w-50" i18n>Alias</div>
<div class="pl-1 w-50">{{ user.alias }}</div>
</div>
<div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
class="d-flex py-1"

View File

@ -3,8 +3,8 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<h1 class="mb-1">Hallo Ghostfolio 👋</h1>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>

View File

@ -3,8 +3,8 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
<div class="text-muted"><small>31.07.2021</small></div>
<h1 class="mb-1">Hello Ghostfolio 👋</h1>
<div class="text-muted"><small>2021-07-31</small></div>
</div>
<section class="mb-4">
<p>

View File

@ -3,11 +3,11 @@
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1" i18n>
<h1 class="mb-1">
👻 Ghostfolio
<span class="text-nowrap">First months in Open Source</span>
</h1>
<div class="text-muted"><small>05.01.2022</small></div>
<div class="text-muted"><small>2022-01-05</small></div>
</div>
<section class="mb-4">
<p>

View File

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

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-ghostfolio-meets-internet-identity-page',
styleUrls: ['./ghostfolio-meets-internet-identity-page.scss'],
templateUrl: './ghostfolio-meets-internet-identity-page.html'
})
export class GhostfolioMeetsInternetIdentityPageComponent {}

View File

@ -0,0 +1,183 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio meets Internet Identity</h1>
<div class="mb-3 text-muted"><small>2022-07-23</small></div>
<img
alt="Ghostfolio meets Internet Identity Teaser"
class="w-100"
src="./assets/images/blog/ghostfolio-meets-internet-identity.png"
title="Ghostfolio meets Internet Identity"
/>
</div>
<section class="mb-4">
<p>
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, supports passwordless authentication as
of now thanks to the integration of
<a href="https://identity.ic0.app">Internet Identity</a>. This
blockchain authentication system enables you to sign in securely and
anonymously to Ghostfolio. With this latest update, Ghostfolio is
ready for Web3.
</p>
<div class="container my-4">
<div class="row">
<div class="col-md-10 offset-md-1">
<blockquote class="blockquote m-0">
<p class="mb-0">Track your portfolio without being tracked</p>
</blockquote>
</div>
</div>
</div>
<p>
To avoid the security issues that arise with password authentication
on the traditional web, the
<a href="https://internetcomputer.org">Internet Computer</a>
blockchain by <a href="https://dfinity.org">dfinity</a> has
introduced a new cryptographic authentication system. It is called
<i>Internet Identity</i> and is as convenient to use as Web2
<a href="https://en.wikipedia.org/wiki/OAuth">OAuth</a> ("Open
Authorization") providers like <i>Google Sign-In</i> or
<i>Facebook Login</i>.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to use Internet Identity?</h2>
<p>
<i>Internet Identity</i> is based on the
<a href="https://en.wikipedia.org/wiki/WebAuthn"
>WebAuthn protocol</a
>
and uses secure cryptographic authentication. It provides three
options to authenticate yourself:
</p>
<ul>
<li>
The built-in biometric authentication methods of your smartphone
or laptop (fingerprint sensor, <i>Face ID</i>, <i>Touch ID</i>)
</li>
<li>The password or pin to unlock your computer or mobile phone</li>
<li>A security key plugged into the USB port of your computer</li>
</ul>
<p>
When you authenticate with <i>Internet Identity</i>, the service
only gets a dedicated pseudonym rather than sensitive user data like
the email address or phone number. This preserves your anonymity and
prevents you being tracked on the Internet.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The key benefits in a nutshell</h2>
<ul>
<li>
Authenticate yourself securely without the need of an email
address, username, or a password: all you need is your device to
log in.
</li>
<li>
Built-in recovery mechanisms to ensure you are not locked out of
any service that requires the <i>Internet Identity</i>.
</li>
<li>
Log in to various Internet services without being tracked by big
tech companies.
</li>
</ul>
</section>
<section class="mb-4">
<p>
If you would like to provide feedback or get involved in further
development of Ghostfolio, please get in touch by email via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Anonymity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Auth Provider</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Authentication</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Blockchain</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptography</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">dfinity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Face ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fingerprint</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Computer</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internet Identity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OAuth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Password</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">passwordless</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Security</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Touch ID</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">WebAuthn</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsInternetIdentityRoutingModule } from './ghostfolio-meets-internet-identity-page-routing.module';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
@NgModule({
declarations: [GhostfolioMeetsInternetIdentityPageComponent],
imports: [
CommonModule,
GhostfolioMeetsInternetIdentityRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsInternetIdentityPageModule {}

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
const routes: Routes = [
{
path: '',
component: HowDoIGetMyFinancesInOrderPageComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HowDoIGetMyFinancesInOrderRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-how-do-i-get-my-finances-in-order-page',
styleUrls: ['./how-do-i-get-my-finances-in-order-page.scss'],
templateUrl: './how-do-i-get-my-finances-in-order-page.html'
})
export class HowDoIGetMyFinancesInOrderPageComponent {}

View File

@ -0,0 +1,209 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">How do I get my finances in order?</h1>
<div class="text-muted"><small>2022-07-14</small></div>
</div>
<section class="mb-4">
<p>
Before you can think of
<a [routerLink]="['/resources']">long-term investing</a>, you have
to get your finances in order. Take a look at Peter's journey to see
how you can achieve it, too.
</p>
<p>
Peter enjoys life, but sometimes he overspends a bit. He realizes it
when money runs out already in the middle of the month. Then the
next few days become difficult and saving money is out of the
question. That is why he wants to plan his monthly budget in the
future.
</p>
<p>
Peter has a decent salary in his job. But as soon as the salary
arrives in his account, it melts away. In order to find out where
his money is disappearing, he has decided to plan his monthly
budget. He wants to be able to put money aside for major expenses
and set financial goals.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Keeping a traditional or digital budget book</h2>
<p>
First, Peter obtains an overview of his personal finances. To do so,
he starts keeping a budget book. This can be done on paper by
listing his income and expenses for a few months, or he can create a
simple spreadsheet in Excel. In addition, many credit card providers
offer the feature within their apps of having expenses automatically
analyzed according to different categories. According to the
<a href="https://www.bfs.admin.ch/bfs/en/home.html"
>Swiss Federal Statistical Office</a
>, households in Switzerland spend around 20 percent of their
disposable income on housing and around 10 percent on groceries.
</p>
<p>
With the smartphone app, Peter has a better overview of his
financial affairs. The application assigns the bookings to
individual categories. Peter can assign specific budgets to each of
them. This way, he is always informed about how much money he can
still spend on restaurant visits in the current month, for example.
A traditional method is the so-called
<a
href="https://www.investopedia.com/envelope-budgeting-system-5208026"
>envelope method</a
>. One envelope is labeled for each category like groceries, rent or
student loans. The monthly budget is put into the envelopes in cash.
Many apps offer the same budgeting system in a more convenient,
virtual way.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Planning and investing</h2>
<p>
If Peter has spent less money than planned on eating out at
restaurants, he can set aside the remaining amount. This way, he can
treat himself to something special every now and then. From now on,
he saves a fixed amount of money in a separate account (“pay
yourself first”) by standing order at the beginning of the month. As
soon as there are three net monthly salaries in the account, he
invests the monthly savings amount in a passively managed global
equity fund. This grows his assets over the years and allows him to
supplement his pension later.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to achieve your financial goals?</h2>
<p>
If you follow these five actionable tips, you can reach your
financial goals easier and faster. Start with one tip and when you
implement it well, you can try the next one to ultimately have more
money at the end of the month.
</p>
<h3 class="h5">1. Visualize your goals</h3>
<p>
Start visualizing your goals. For example, hang up pictures of the
travel destination you are saving for. Imagine that you have already
achieved the goal to slowly adapt your mindset.
</p>
<h3 class="h5">2. Write off personal items</h3>
<p>
Do as a business does and write off purchases annually. For a new
car, you could set aside one-sixth of the purchase price each year.
</p>
<h3 class="h5">3. Save at the beginning of the month</h3>
<p>
Have a savings amount deducted from your account at the beginning of
the month. Then you will pay yourself first and spend less money.
</p>
<h3 class="h5">4. Follow the 50-30-20 rule</h3>
<p>
You need 50 percent of your disposable income for fixed costs. 30
percent can be spent on personal needs such as hobbies, travel or
consumer electronics. 20 percent is left for savings or to pay off
potential debts.
</p>
<h3 class="h5">5. Track your progress</h3>
<p>
If you have any money to spare, invest it in a broadly diversified,
low-cost portfolio excluding the risks of individual stocks. Track
the progress of your portfolio and net worth with
<a href="https://ghostfol.io">Ghostfolio</a>, a web-based personal
finance management software.
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Assets</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budget</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cash</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Debt</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Equity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Expense</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fund</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Goal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Guide</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Income</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Money</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Net Worth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Pension</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Salary</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Saving</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Spreadsheet</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HowDoIGetMyFinancesInOrderRoutingModule } from './how-do-i-get-my-finances-in-order-page-routing.module';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
@NgModule({
declarations: [HowDoIGetMyFinancesInOrderPageComponent],
imports: [
CommonModule,
HowDoIGetMyFinancesInOrderRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HowDoIGetMyFinancesInOrderPageModule {}

View File

@ -2,10 +2,62 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Blog</h3>
<mat-card class="blog-container">
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap mb-3 no-gutters row">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '07', 'ghostfolio-meets-internet-identity']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
Ghostfolio meets Internet Identity
</div>
<div class="d-flex text-muted">2022-07-23</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '07', 'how-do-i-get-my-finances-in-order']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">
How do I get my finances in order?
</div>
<div class="d-flex text-muted">2022-07-14</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2022', '01', 'ghostfolio-first-months-in-open-source']"
@ -14,7 +66,7 @@
<div class="h6 m-0 text-truncate">
First months in Open Source
</div>
<div class="d-flex text-muted">05.01.2022</div>
<div class="d-flex text-muted">2022-01-05</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
@ -25,14 +77,20 @@
</div>
</a>
</div>
<div class="flex-nowrap mb-3 no-gutters row">
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
<div class="d-flex text-muted">2021-07-31</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
@ -43,6 +101,12 @@
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex w-100"
@ -50,7 +114,7 @@
>
<div class="flex-grow-1">
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
<div class="d-flex text-muted">31.07.2021</div>
<div class="d-flex text-muted">2021-07-31</div>
</div>
<div class="align-items-center d-flex">
<ion-icon

View File

@ -4,6 +4,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -22,6 +23,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[];
public mode: GroupBy;
public modeOptions: ToggleOption[] = [
{ label: 'Monthly', value: 'month' },
{ label: 'Accumulating', value: undefined }
];
public top3: Position[];
public user: User;
@ -55,6 +62,15 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchInvestmentsByMonth()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ investments }) => {
this.investmentsByMonth = investments;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPositions({ range: 'max' })
.pipe(takeUntil(this.unsubscribeSubject))
@ -86,6 +102,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
});
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,17 +1,47 @@
<div class="container">
<div class="investment-chart row">
<div class="row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div class="mb-3">
<div class="h5 mb-3" i18n>Investment Timeline</div>
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
></gf-investment-chart>
<div class="mb-4">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Investment Timeline</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[isLoading]="false"
[options]="modeOptions"
(change)="onChangeGroupBy($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': mode }"
></gf-investment-chart>
<gf-investment-chart
class="h-100"
groupBy="month"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investmentsByMonth"
[locale]="user?.settings?.locale"
[ngClass]="{ 'd-none': !mode }"
></gf-investment-chart>
</div>
</div>
</div>
</div>

View File

@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -14,6 +16,8 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule,
CommonModule,
GfInvestmentChartModule,
GfPremiumIndicatorModule,
GfToggleModule,
GfValueModule,
MatCardModule,
NgxSkeletonLoaderModule

View File

@ -1,11 +1,7 @@
:host {
display: block;
.investment-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
}
}
.chart-container {
aspect-ratio: 16 / 9;
}
}

View File

@ -15,10 +15,14 @@
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<div
*ngIf="hasPermissionToCreateOrder && positionsArray?.length > 0"
class="text-center"
>
<a
class="mt-3"
i18n

View File

@ -255,6 +255,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
isUUID(this.activityForm.controls['searchSymbol'].value.symbol)
? this.activityForm.controls['name'].value
: this.activityForm.controls['searchSymbol'].value.symbol,
tags: this.activityForm.controls['tags'].value,
type: this.activityForm.controls['type'].value,
unitPrice: this.activityForm.controls['unitPrice'].value
};

View File

@ -1,6 +1,6 @@
<gf-dialog-header
mat-dialog-title
title="Import Transactions Error"
title="Import Activities Error"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>

View File

@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -34,6 +35,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private internetIdentityService: InternetIdentityService,
private router: Router,
private tokenStorageService: TokenStorageService
) {
@ -62,6 +64,14 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
});
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
} catch {}
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string,

View File

@ -28,16 +28,25 @@
Create Account
</button>
<ng-container *ngIf="hasPermissionForSocialLogin">
<div
class="m-3 text-muted"
i18n
[ngClass]="{'d-inline': deviceType !== 'mobile' }"
<div class="my-3 text-muted" i18n>or</div>
<button
class="d-block mb-2"
mat-stroked-button
(click)="onLoginWithInternetIdentity()"
>
or
</div>
<a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Continue with Google</span></a
<img
class="mr-2"
src="./assets/icons/internet-computer.svg"
style="height: 0.75rem"
/>
<span i18n>Continue with Internet Identity</span>
</button>
<a class="d-block" href="/api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="./assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Continue with Google</span></a
>
</ng-container>
</div>

View File

@ -20,6 +20,22 @@
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">How do I get my finances in order?</h3>
<div class="mb-1">
Before you can think of long-term investing, you have to get your
finances in order. Learn how you can reach your financial goals
easier and faster in this guide.
</div>
<div>
<a
[routerLink]="['/en', 'blog', '2022', '07', 'how-do-i-get-my-finances-in-order']"
>How do I get my finances in order? →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Market</h2>
<div class="mb-5">

View File

@ -23,6 +23,7 @@ import {
Export,
Filter,
InfoItem,
OAuthResponse,
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
@ -204,6 +205,22 @@ export class DataService {
);
}
public fetchInvestmentsByMonth(): Observable<PortfolioInvestments> {
return this.http
.get<any>('/api/v1/portfolio/investments', {
params: { groupBy: 'month' }
})
.pipe(
map((response) => {
if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate);
}
return response;
})
);
}
public fetchSymbolItem({
dataSource,
includeHistoricalData,
@ -352,7 +369,9 @@ export class DataService {
}
public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/v1/auth/anonymous/${accessToken}`);
return this.http.get<OAuthResponse>(
`/api/v1/auth/anonymous/${accessToken}`
);
}
public postAccess(aAccess: CreateAccessDto) {

View File

@ -0,0 +1,55 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, OnDestroy } from '@angular/core';
import { AuthClient } from '@dfinity/auth-client';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class InternetIdentityService implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {}
public async login(): Promise<OAuthResponse> {
const authClient = await AuthClient.create({
idleOptions: {
disableDefaultIdleCallback: true,
disableIdle: true
}
});
return new Promise((resolve, reject) => {
authClient.login({
onError: async () => {
return reject();
},
onSuccess: () => {
const principalId = authClient.getIdentity().getPrincipal();
this.http
.get<OAuthResponse>(
`/api/v1/auth/internet-identity/${principalId.toText()}`
)
.pipe(
catchError(() => {
reject();
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((response) => {
resolve(response);
});
}
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 128 128" id="Social_Icons" version="1.1" viewBox="0 0 128 128" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="_x31__stroke"><g id="Google"><rect clip-rule="evenodd" fill="none" fill-rule="evenodd" height="128" width="128"/><path clip-rule="evenodd" d="M27.585,64c0-4.157,0.69-8.143,1.923-11.881L7.938,35.648 C3.734,44.183,1.366,53.801,1.366,64c0,10.191,2.366,19.802,6.563,28.332l21.558-16.503C28.266,72.108,27.585,68.137,27.585,64" fill="#FBBC05" fill-rule="evenodd"/><path clip-rule="evenodd" d="M65.457,26.182c9.031,0,17.188,3.2,23.597,8.436L107.698,16 C96.337,6.109,81.771,0,65.457,0C40.129,0,18.361,14.484,7.938,35.648l21.569,16.471C34.477,37.033,48.644,26.182,65.457,26.182" fill="#EA4335" fill-rule="evenodd"/><path clip-rule="evenodd" d="M65.457,101.818c-16.812,0-30.979-10.851-35.949-25.937 L7.938,92.349C18.361,113.516,40.129,128,65.457,128c15.632,0,30.557-5.551,41.758-15.951L86.741,96.221 C80.964,99.86,73.689,101.818,65.457,101.818" fill="#34A853" fill-rule="evenodd"/><path clip-rule="evenodd" d="M126.634,64c0-3.782-0.583-7.855-1.457-11.636H65.457v24.727 h34.376c-1.719,8.431-6.397,14.912-13.092,19.13l20.474,15.828C118.981,101.129,126.634,84.861,126.634,64" fill="#4285F4" fill-rule="evenodd"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 358.8 179.8" style="enable-background:new 0 0 358.8 179.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#29ABE2;}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="224.7853" y1="257.7536" x2="348.0663" y2="133.4581" gradientTransform="matrix(1 0 0 -1 0 272)">
<stop offset="0.21" style="stop-color:#F15A24"/>
<stop offset="0.6841" style="stop-color:#FBB03B"/>
</linearGradient>
<path class="st0" d="M271.6,0c-20,0-41.9,10.9-65,32.4c-10.9,10.1-20.5,21.1-27.5,29.8c0,0,11.2,12.9,23.5,26.8
c6.7-8.4,16.2-19.8,27.3-30.1c20.5-19.2,33.9-23.1,41.6-23.1c28.8,0,52.2,24.2,52.2,54.1c0,29.6-23.4,53.8-52.2,54.1
c-1.4,0-3-0.2-5-0.6c8.4,3.9,17.5,6.7,26,6.7c52.8,0,63.2-36.5,63.8-39.1c1.5-6.7,2.4-13.7,2.4-20.9C358.6,40.4,319.6,0,271.6,0z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="133.9461" y1="106.4262" x2="10.6653" y2="230.7215" gradientTransform="matrix(1 0 0 -1 0 272)">
<stop offset="0.21" style="stop-color:#ED1E79"/>
<stop offset="0.8929" style="stop-color:#522785"/>
</linearGradient>
<path class="st1" d="M87.1,179.8c20,0,41.9-10.9,65-32.4c10.9-10.1,20.5-21.1,27.5-29.8c0,0-11.2-12.9-23.5-26.8
c-6.7,8.4-16.2,19.8-27.3,30.1c-20.5,19-34,23.1-41.6,23.1c-28.8,0-52.2-24.2-52.2-54.1c0-29.6,23.4-53.8,52.2-54.1
c1.4,0,3,0.2,5,0.6c-8.4-3.9-17.5-6.7-26-6.7C13.4,29.6,3,66.1,2.4,68.8C0.9,75.5,0,82.5,0,89.7C0,139.4,39,179.8,87.1,179.8z"/>
<path class="st2" d="M127.3,59.7c-5.8-5.6-34-28.5-61-29.3C18.1,29.2,4,64.2,2.7,68.7C12,29.5,46.4,0.2,87.2,0
c33.3,0,67,32.7,91.9,62.2c0,0,0.1-0.1,0.1-0.1c0,0,11.2,12.9,23.5,26.8c0,0,14,16.5,28.8,31c5.8,5.6,33.9,28.2,60.9,29
c49.5,1.4,63.2-35.6,63.9-38.4c-9.1,39.5-43.6,68.9-84.6,69.1c-33.3,0-67-32.7-92-62.2c0,0.1-0.1,0.1-0.1,0.2
c0,0-11.2-12.9-23.5-26.8C156.2,90.8,142.2,74.2,127.3,59.7z M2.7,69.1c0-0.1,0-0.2,0.1-0.3C2.7,68.9,2.7,69,2.7,69.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -6,54 +6,62 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about/changelog</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/blog</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/demo</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/features</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/markets</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pricing</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/register</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/resources</loc>
<lastmod>2022-07-01T00:00:00+00:00</lastmod>
<lastmod>2022-07-23T00:00:00+00:00</lastmod>
</url>
</urlset>

View File

@ -43,7 +43,7 @@ export function getTooltipPositionerMapTop(
chart: Chart,
position: TooltipPosition
) {
if (!position) {
if (!position || !chart?.chartArea) {
return false;
}
return {

View File

@ -5,7 +5,6 @@ export interface AdminData {
userCount: number;
users: {
accountCount: number;
alias: string;
createdAt: Date;
engagement: number;
id: string;

View File

@ -29,6 +29,7 @@ import { PortfolioSummary } from './portfolio-summary.interface';
import { Position } from './position.interface';
import { BenchmarkResponse } from './responses/benchmark-response.interface';
import { ResponseError } from './responses/errors.interface';
import { OAuthResponse } from './responses/oauth-response.interface';
import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import { ScraperConfiguration } from './scraper-configuration.interface';
import { TimelinePosition } from './timeline-position.interface';
@ -54,6 +55,7 @@ export {
FilterGroup,
HistoricalDataItem,
InfoItem,
OAuthResponse,
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,

View File

@ -0,0 +1,3 @@
export interface OAuthResponse {
authToken: string;
}

View File

@ -8,7 +8,6 @@ export interface User {
id: string;
}[];
accounts: Account[];
alias?: string;
id: string;
permissions: string[];
settings: UserSettings;

View File

@ -0,0 +1 @@
export type GroupBy = 'month';

View File

@ -2,7 +2,8 @@ import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { AccountWithValue } from './account-with-value.type';
import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import { MarketState } from './market-state-type';
import { GroupBy } from './group-by.type';
import { MarketState } from './market-state.type';
import { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type';
@ -13,6 +14,7 @@ export type {
AccountWithValue,
DateRange,
Granularity,
GroupBy,
Market,
MarketState,
OrderWithAccount,

View File

@ -8,6 +8,5 @@
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@ -175,6 +175,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
data,
options: {
animation: false,
aspectRatio: 16 / 9,
elements: {
point: {
hoverBackgroundColor: getBackgroundColor(),

View File

@ -1,5 +1,6 @@
<a
class="align-items-center d-flex"
title="Upgrade to Ghostfolio Premium"
[ngStyle]="{ 'pointer-events': enableLink ? 'initial' : 'none' }"
[routerLink]="['/pricing']"
><ion-icon class="text-muted" name="diamond-outline"></ion-icon

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.167.0",
"version": "1.173.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -20,6 +20,7 @@
"database:format-schema": "prisma format",
"database:generate-typings": "prisma generate",
"database:gui": "prisma studio",
"database:gui:prod": "npx dotenv-cli -e .env.prod -- prisma studio",
"database:migrate": "prisma migrate deploy",
"database:push": "prisma db push",
"database:seed": "prisma db seed",
@ -42,7 +43,7 @@
"start:server": "nx serve api --watch",
"start:storybook": "nx run ui:storybook",
"test": "nx test",
"test:single": "nx test --test-file portfolio-calculator-new.spec.ts",
"test:single": "nx test --test-file portfolio-calculator-novn-buy-and-sell-partially.spec.ts",
"ts-node": "ts-node",
"update": "nx migrate latest",
"watch:server": "nx build api --watch",
@ -61,6 +62,12 @@
"@angular/platform-browser-dynamic": "14.0.2",
"@angular/router": "14.0.2",
"@codewithdan/observable-store": "2.2.11",
"@dfinity/agent": "0.12.1",
"@dfinity/auth-client": "0.12.1",
"@dfinity/authentication": "0.12.1",
"@dfinity/candid": "0.12.1",
"@dfinity/identity": "0.12.1",
"@dfinity/principal": "0.12.1",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@nestjs/bull": "0.5.5",
"@nestjs/common": "8.4.7",
@ -93,7 +100,7 @@
"color": "4.0.1",
"countries-list": "2.6.1",
"countup.js": "2.0.7",
"date-fns": "2.22.1",
"date-fns": "2.28.0",
"envalid": "7.3.1",
"google-spreadsheet": "3.2.0",
"http-status-codes": "2.2.0",
@ -171,9 +178,9 @@
"prettier": "2.7.1",
"replace-in-file": "6.2.0",
"rimraf": "3.0.2",
"tslib": "2.0.0",
"ts-jest": "27.1.4",
"ts-node": "10.8.1",
"tslib": "2.0.0",
"typescript": "4.7.3"
},
"engines": {

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Provider" ADD VALUE 'INTERNET_IDENTITY';

View File

@ -217,6 +217,7 @@ enum ViewMode {
enum Provider {
ANONYMOUS
GOOGLE
INTERNET_IDENTITY
}
enum Role {

View File

@ -0,0 +1,28 @@
{
"meta": {
"date": "2022-07-21T21:28:05.857Z",
"version": "dev"
},
"activities": [
{
"fee": 0,
"quantity": 1,
"type": "SELL",
"unitPrice": 85.73,
"currency": "CHF",
"dataSource": "YAHOO",
"date": "2022-04-07T22:00:00.000Z",
"symbol": "NOVN.SW"
},
{
"fee": 0,
"quantity": 2,
"type": "BUY",
"unitPrice": 75.8,
"currency": "CHF",
"dataSource": "YAHOO",
"date": "2022-03-06T23:00:00.000Z",
"symbol": "NOVN.SW"
}
]
}

126
yarn.lock
View File

@ -1769,6 +1769,48 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@dfinity/agent@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/agent/-/agent-0.12.1.tgz#c3acd6419712aca77bf94f76057f100caeb69be4"
integrity sha512-/KSKh248k4pjzvqCzIgYNNi3pTv+DBZ40+QiTBQeFzp6VEg3gfSv5bK2UwC0Plq9xwk7TeeeGLiTv6DI3RjCOQ==
dependencies:
base64-arraybuffer "^0.2.0"
bignumber.js "^9.0.0"
borc "^2.1.1"
js-sha256 "0.9.0"
simple-cbor "^0.4.1"
"@dfinity/auth-client@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/auth-client/-/auth-client-0.12.1.tgz#8043aeafe8ba8a000954f94de25a76a2565acafc"
integrity sha512-iZKSVjk9K+35jp+AY3QfGAv0jBfn5LZTwpSXgBKVqZCez3GRniGJirJVTvk7t9yOj4BXN8tuvjIKxTsezPpgLQ==
"@dfinity/authentication@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/authentication/-/authentication-0.12.1.tgz#19f157e6eb528518da874f3e10d3f7b2b028e5a4"
integrity sha512-krHR48HNqTOp2NwHoKHirTUXHDfHttWZfSmwBCsQa0xwWkrrLSGb3u+9e1oQjDK1G1eK2TP7T1W2duZmmmrZkg==
"@dfinity/candid@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/candid/-/candid-0.12.1.tgz#6ba819c56bc3ff55f6f98bdb39470d1d385084b6"
integrity sha512-YX8jfyy/8Qmz4f1mbjqXUqOmtYcGru1gfYWxlRhKFSkeLH0VeZkfPEmD6EQ25k+18ATPk83MQiZnu0b6AWxBUw==
"@dfinity/identity@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/identity/-/identity-0.12.1.tgz#f19bf2ed6bfbbb1f4e7c859c039e4da8dee81857"
integrity sha512-FNrjV4/gG9PjQfGLIoH1DycqSAMaoTZCxB+cSVJRFCvGQKc3F3kn5tj6rIv9LV+NNV1f1qfmTXE8rYsMCmEecg==
dependencies:
"@types/webappsec-credential-management" "^0.6.2"
borc "^2.1.1"
js-sha256 "^0.9.0"
secp256k1 "^4.0.2"
tweetnacl "^1.0.1"
"@dfinity/principal@0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@dfinity/principal/-/principal-0.12.1.tgz#d4b1f088beded6c1f1b2efbe68a0632e0b4018f7"
integrity sha512-aL5y0mpzRex6LRSc4LUZyhn2GTFfHyxkakkOZxEM7+ecz8HsKKK+mSo78gL1TCso2QkCL4BqZzxnoIxxKqM1cw==
"@dinero.js/currencies@2.0.0-alpha.8":
version "2.0.0-alpha.8"
resolved "https://registry.yarnpkg.com/@dinero.js/currencies/-/currencies-2.0.0-alpha.8.tgz#aa02a04ce3685a9b06a7ce12f8c924726386c3fd"
@ -4151,6 +4193,11 @@
resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.3.tgz#3193c0a3c03a7d1189016c62b4fba4b149ef5e33"
integrity sha512-DNviAE5OUcZ5s+XEQHRhERLg8fOp8gSgvyJ4aaFASx5wwaObm+PBwTIMXiOFm1QrSee5oYwEAYb7LMzX2O88gA==
"@types/webappsec-credential-management@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.2.tgz#93491de1ffcf57f6558c78949cc8e6c5d826b94e"
integrity sha512-/6w8wmKQOFh+1CL99fcFhH7lli1/ExBdAawXsVPXFC5MBOS6ww/4cmK4crpCw51RaG6sTr477N17Y84l0G69IA==
"@types/webpack-env@^1.16.0":
version "1.17.0"
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.17.0.tgz#f99ce359f1bfd87da90cc4a57cab0a18f34a48d0"
@ -5484,6 +5531,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -5651,6 +5703,19 @@ bootstrap@4.6.0:
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.0.tgz#97b9f29ac98f98dfa43bf7468262d84392552fd7"
integrity sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==
borc@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/borc/-/borc-2.1.2.tgz#6ce75e7da5ce711b963755117dd1b187f6f8cf19"
integrity sha512-Sy9eoUi4OiKzq7VovMn246iTo17kzuyHJKomCfpWMlI6RpfN1gk95w7d7gH264nApVLg0HZfcpz62/g4VH1Y4w==
dependencies:
bignumber.js "^9.0.0"
buffer "^5.5.0"
commander "^2.15.0"
ieee754 "^1.1.13"
iso-url "~0.4.7"
json-text-sequence "~0.1.0"
readable-stream "^3.6.0"
boxen@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50"
@ -6582,7 +6647,7 @@ comma-separated-tokens@^1.0.0:
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea"
integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==
commander@2, commander@^2.11.0, commander@^2.20.0:
commander@2, commander@^2.11.0, commander@^2.15.0, commander@^2.20.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@ -7710,10 +7775,10 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@2.22.1:
version "2.22.1"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4"
integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==
date-fns@2.28.0:
version "2.28.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2"
integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==
date-fns@^1.27.2:
version "1.30.1"
@ -7856,6 +7921,11 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
delimit-stream@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/delimit-stream/-/delimit-stream-0.1.0.tgz#9b8319477c0e5f8aeb3ce357ae305fc25ea1cd2b"
integrity sha512-a02fiQ7poS5CnjiJBAsjGLPp5EwVoGHNeu9sziBd9huppRfsAFIpv5zNLv0V1gbop53ilngAf5Kf331AwcoRBQ==
denque@^1.1.0, denque@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf"
@ -11247,6 +11317,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
iso-url@~0.4.7:
version "0.4.7"
resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.7.tgz#de7e48120dae46921079fe78f325ac9e9217a385"
integrity sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog==
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
@ -11827,6 +11902,11 @@ jest@27.5.1:
import-local "^3.0.2"
jest-cli "^27.5.1"
js-sha256@0.9.0, js-sha256@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==
js-string-escape@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
@ -11942,6 +12022,13 @@ json-stringify-safe@~5.0.1:
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
json-text-sequence@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/json-text-sequence/-/json-text-sequence-0.1.1.tgz#a72f217dc4afc4629fff5feb304dc1bd51a2f3d2"
integrity sha512-L3mEegEWHRekSHjc7+sc8eJhba9Clq1PZ8kMkzf8OxElhXc8O4TS5MwcVlj9aEbm5dr81N90WHC5nAz3UO971w==
dependencies:
delimit-stream "0.1.0"
json5@2.x, json5@^2.1.2, json5@^2.1.3, json5@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
@ -13202,6 +13289,11 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
node-addon-api@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32"
integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==
node-addon-api@^3.0.0, node-addon-api@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161"
@ -13224,6 +13316,11 @@ node-gyp-build-optional-packages@5.0.2:
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.2.tgz#3de7d30bd1f9057b5dfbaeab4a4442b7fe9c5901"
integrity sha512-PiN4NWmlQPqvbEFcH/omQsswWQbe5Z9YK/zdB23irp5j2XibaA2IrGvpSWmVVG4qMZdmPdwPctSy4a86rOMn6g==
node-gyp-build@^4.2.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40"
integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==
node-gyp-build@^4.2.2, node-gyp-build@^4.3.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.4.0.tgz#42e99687ce87ddeaf3a10b99dc06abc11021f3f4"
@ -15735,6 +15832,15 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1"
ajv-keywords "^5.0.0"
secp256k1@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303"
integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA==
dependencies:
elliptic "^6.5.4"
node-addon-api "^2.0.0"
node-gyp-build "^4.2.0"
secure-compare@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/secure-compare/-/secure-compare-3.0.1.tgz#f1a0329b308b221fae37b9974f3d578d0ca999e3"
@ -15950,6 +16056,11 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
simple-cbor@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/simple-cbor/-/simple-cbor-0.4.1.tgz#0c88312e87db52b94e0e92f6bd1cf634e86f8a22"
integrity sha512-rijcxtwx2b4Bje3sqeIqw5EeW7UlOIC4YfOdwqIKacpvRQ/D78bWg/4/0m5e0U91oKvlGh7LlJuZCu07ISCC7w==
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
@ -17120,6 +17231,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
tweetnacl@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
twitter-api-v2@1.10.3:
version "1.10.3"
resolved "https://registry.yarnpkg.com/twitter-api-v2/-/twitter-api-v2-1.10.3.tgz#07441bd9c4d27433aa0284d900cf60f6328b8239"