Compare commits

...

9 Commits

Author SHA1 Message Date
bd4608e521 Release 1.138.0 (#838) 2022-04-16 21:03:28 +02:00
0d8362ca8f Feature/separate deposit and savings in fire calculator (#837)
* Separate deposit and savings

* Update changelog
2022-04-16 21:01:55 +02:00
638ae3f7fa Feature/add boringly getting rich guide to resources page (#836)
* Add "Boringly Getting Rich" guide

* Update changelog
2022-04-16 15:35:31 +02:00
6e7cf0380b Feature/export single draft (#835)
* Export single draft

* Update changelog
2022-04-16 11:33:01 +02:00
ec2ecab751 Clean up name (#834) 2022-04-16 10:21:32 +02:00
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
21 changed files with 477 additions and 167 deletions

View File

@ -5,6 +5,31 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.138.0 - 16.04.2022
### Added
- Added support to export a single future activity (draft) as an `.ics` file
- Added the _Boringly Getting Rich_ guide to the resources section
### Changed
- Separated the deposit and savings in the chart of the the _FIRE_ calculator
## 1.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as an `.ics` file
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022 ## 1.136.0 - 13.04.2022
### Changed ### Changed

View File

@ -42,6 +42,7 @@ export class ExportService {
accountId, accountId,
date, date,
fee, fee,
id,
quantity, quantity,
SymbolProfile, SymbolProfile,
type, type,
@ -49,13 +50,14 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
date,
fee, fee,
id,
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
}; };
} }

View File

@ -20,6 +20,13 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
} }

View File

@ -0,0 +1,96 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

View File

@ -77,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity const newQuantity = order.quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = { currentTransactionPointItem = {
investment,
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee), fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity, quantity: newQuantity,
symbol: order.symbol, symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1

View File

@ -17,8 +17,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService

View File

@ -16,7 +16,6 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
@ -25,8 +24,6 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -244,16 +241,7 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const searchResult = await yahooFinance.search(aQuery);
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const searchResult = await get();
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
@ -279,20 +267,24 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}); });
const marketData = await this.getQuotes( const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => { quotes.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
for (const [symbol, value] of Object.entries(marketData)) { for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote: any) => { const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === symbol; return currentQuote.symbol === marketDataItem.symbol;
}); });
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol name: quote?.longname || quote?.shortname || symbol
}); });

View File

@ -194,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -68,12 +68,12 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(userItem.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -211,14 +211,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
) )
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( downloadAsFile({
data, content: data,
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format( fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
}); });
} }

View File

@ -7,6 +7,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService, private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds) .fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( for (const activity of data.activities) {
data, delete activity.id;
`ghostfolio-export-${format( }
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
});
}
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent(
data.activities
),
contentType: 'text/calendar',
fileName: `ghostfolio-draft${
data.activities.length > 1 ? 's' : ''
}-${format(parseISO(data.meta.date), 'yyyyMMddHHmm')}.ics`,
format: 'string'
});
}); });
} }

View File

@ -15,6 +15,7 @@
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport($event)" (export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -1,18 +1,35 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Resources</h3> <h1 class="d-flex h3 justify-content-center mb-3" i18n>Resources</h1>
<mat-card class="mb-3"> <h2 class="h4 mb-3">Guides</h2>
<mat-card-content>
<h4 class="mb-3">Market</h4>
<div class="mb-5"> <div class="mb-5">
<div class="mb-4 media"> <div class="mb-4 media">
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Fear & Greed Index</h5> <h3 class="h5 mt-0">Boringly Getting Rich</h3>
<div class="mb-1">
The <i>Boringly Getting Rich</i> guide supports you to get started
with investing. It introduces a strategy utilizing a broadly
diversified, low-cost portfolio excluding the risks of individual
stocks.
</div>
<div>
<a href="https://herget.me/investing-guide" target="_blank"
>Boringly Getting Rich →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Market</h2>
<div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Fear & Greed Index</h3>
<div class="mb-1"> <div class="mb-1">
The fear and greed index was developed by <i>CNNMoney</i> to The fear and greed index was developed by <i>CNNMoney</i> to
measure the primary emotions (fear and greed) that influence measure the primary emotions (fear and greed) that influence how
how much investors are willing to pay for stocks. much investors are willing to pay for stocks.
</div> </div>
<div> <div>
<a <a
@ -25,12 +42,12 @@
</div> </div>
<div class="media"> <div class="media">
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Inflation Chart</h5> <h3 class="h5 mt-0">Inflation Chart</h3>
<div class="mb-1"> <div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to markets, stock prices, goods and services by adjusting them to the
the amount of the money supply (M0, M1, M2) or price of other amount of the money supply (M0, M1, M2) or price of other goods
goods (food or oil). (food or oil).
</div> </div>
<div> <div>
<a href="https://inflationchart.com" target="_blank" <a href="https://inflationchart.com" target="_blank"
@ -40,16 +57,15 @@
</div> </div>
</div> </div>
</div> </div>
<h4 class="mb-3">Glossary</h4> <h2 class="h4 mb-3">Glossary</h2>
<div> <div>
<div class="mb-4 media"> <div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Buy and Hold</h5> <h3 class="h5 mt-0">Buy and Hold</h3>
<div class="mb-1"> <div class="mb-1">
Buy and hold is a passive investment strategy where you buy Buy and hold is a passive investment strategy where you buy assets
assets and hold them for a long period regardless of and hold them for a long period regardless of fluctuations in the
fluctuations in the market. market.
</div> </div>
<div> <div>
<a <a
@ -61,14 +77,13 @@
</div> </div>
</div> </div>
<div class="mb-4 media"> <div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Dollar-Cost Averaging (DCA)</h5> <h3 class="h5 mt-0">Dollar-Cost Averaging (DCA)</h3>
<div class="mb-1"> <div class="mb-1">
Dollar-cost averaging is an investment strategy where you Dollar-cost averaging is an investment strategy where you split
split the total amount to be invested across periodic the total amount to be invested across periodic purchases of a
purchases of a target asset to reduce the impact of volatility target asset to reduce the impact of volatility on the overall
on the overall purchase. purchase.
</div> </div>
<div> <div>
<a <a
@ -80,13 +95,12 @@
</div> </div>
</div> </div>
<div class="media"> <div class="media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Financial Independence</h5> <h3 class="h5 mt-0">Financial Independence</h3>
<div class="mb-1"> <div class="mb-1">
Financial independence is the status of having enough income, Financial independence is the status of having enough income, for
for example with a passive income like dividends, to cover example with a passive income like dividends, to cover your living
your living expenses for the rest of your life. expenses for the rest of your life.
</div> </div>
<div> <div>
<a <a
@ -98,8 +112,6 @@
</div> </div>
</div> </div>
</div> </div>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
font-weight: 500; font-weight: 500;
@ -12,7 +11,6 @@
} }
} }
} }
}
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));

View File

@ -0,0 +1,60 @@
import { Injectable } from '@angular/core';
import { capitalize } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { Type } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@Injectable({
providedIn: 'root'
})
export class IcsService {
private readonly ICS_DATE_FORMAT = 'yyyyMMdd';
private readonly ICS_LINE_BREAK = '\r\n';
public constructor() {}
public transformActivitiesToIcsContent(
aActivities: Export['activities']
): string {
const header = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Ghostfolio//NONSGML v1.0//EN'
];
const events = aActivities.map((activity) => {
return this.getEvent({
date: parseISO(activity.date),
id: activity.id,
symbol: activity.symbol,
type: activity.type
});
});
const footer = ['END:VCALENDAR'];
return [...header, ...events, ...footer].join(this.ICS_LINE_BREAK);
}
private getEvent({
date,
id,
symbol,
type
}: {
date: Date;
id: string;
symbol: string;
type: Type;
}) {
const today = format(new Date(), this.ICS_DATE_FORMAT);
return [
'BEGIN:VEVENT',
`UID:${id}`,
`DTSTAMP:${today}T000000`,
`DTSTART;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`DTEND;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`SUMMARY:${capitalize(type)} ${symbol}`,
'END:VEVENT'
].join(this.ICS_LINE_BREAK);
}
}

View File

@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString(); return Buffer.from(encodedDataSource, 'hex').toString();
} }
export function downloadAsFile( export function downloadAsFile({
aContent: unknown, content,
aFileName: string, contentType = 'text/plain',
aContentType: string fileName,
) { format
}: {
content: unknown;
contentType?: string;
fileName: string;
format: 'json' | 'string';
}) {
const a = document.createElement('a'); const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType if (format === 'json') {
content = JSON.stringify(content, undefined, ' ');
}
const file = new Blob([<string>content], {
type: contentType
}); });
a.href = URL.createObjectURL(file); a.href = URL.createObjectURL(file);
a.download = aFileName; a.download = fileName;
a.click(); a.click();
} }

View File

@ -5,5 +5,14 @@ export interface Export {
date: string; date: string;
version: string; version: string;
}; };
activities: Partial<Order>[]; activities: (Omit<
Order,
| 'accountUserId'
| 'createdAt'
| 'date'
| 'isDraft'
| 'symbolProfileId'
| 'updatedAt'
| 'userId'
> & { date: string; symbol: string })[];
} }

View File

@ -271,6 +271,7 @@
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell> <td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true" [isAbsolute]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
@ -302,6 +303,7 @@
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell> <td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true" [isAbsolute]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
@ -356,11 +358,22 @@
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span> <span i18n>Export</span>
</button> </button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span>
</button>
</mat-menu> </mat-menu>
</th> </th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -374,14 +387,25 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #activityMenu="matMenu" xPosition="before"> <mat-menu #activityMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateActivity(element)"> <button mat-menu-item (click)="onUpdateActivity(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button i18n mat-menu-item (click)="onCloneActivity(element)"> <button mat-menu-item (click)="onCloneActivity(element)">
Clone <ion-icon class="mr-2" name="copy-outline"></ion-icon>
<span i18n>Clone</span>
</button> </button>
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)"> <button
Delete mat-menu-item
[disabled]="!element.isDraft"
(click)="onExportDraft(element.id)"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Draft as ICS</span>
</button>
<button mat-menu-item (click)="onDeleteActivity(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -56,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() activityToClone = new EventEmitter<OrderWithAccount>(); @Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>(); @Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<string[]>(); @Output() export = new EventEmitter<string[]>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ -68,6 +69,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public endOfToday = endOfToday(); public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable(); public filters: Observable<string[]> = this.filters$.asObservable();
public hasDrafts = false;
public isAfter = isAfter; public isAfter = isAfter;
public isLoading = true; public isLoading = true;
public isUUID = isUUID; public isUUID = isUUID;
@ -198,6 +200,22 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
} }
} }
public onExportDraft(aActivityId: string) {
this.exportDrafts.emit([aActivityId]);
}
public onExportDrafts() {
this.exportDrafts.emit(
this.dataSource.filteredData
.filter((activity) => {
return activity.isDraft;
})
.map((activity) => {
return activity.id;
})
);
}
public onImport() { public onImport() {
this.import.emit(); this.import.emit();
} }
@ -234,6 +252,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.filters$.next(this.allFilters); this.filters$.next(this.allFilters);
this.hasDrafts = this.dataSource.data.some((activity) => {
return activity.isDraft === true;
});
this.totalFees = this.getTotalFees(); this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue(); this.totalValue = this.getTotalValue();
} }
@ -308,7 +329,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
if (activity.type === 'BUY' || activity.type === 'ITEM') { if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency); totalValue = totalValue.plus(activity.valueInBaseCurrency);
} else if (activity.type === 'SELL') { } else if (activity.type === 'SELL') {
totalValue = totalValue.minus(activity.valueInBaseCurrency); return null;
} }
} else { } else {
return null; return null;

View File

@ -11,7 +11,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { transformTickToAbbreviation } from '@ghostfolio/common/helper'; import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
import { import {
BarController, BarController,
@ -21,6 +21,7 @@ import {
LinearScale, LinearScale,
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
import * as Color from 'color';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -211,16 +212,30 @@ export class FireCalculatorComponent
labels.push(year); labels.push(year);
} }
const datasetDeposit = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
data: [],
label: 'Deposit'
};
const datasetInterest = { const datasetInterest = {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)
.lighten(0.5)
.hex(),
data: [], data: [],
label: 'Interest' label: 'Interest'
}; };
const datasetPrincipal = { const datasetSavings = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)
.lighten(0.25)
.hex(),
data: [], data: [],
label: 'Principal' label: 'Savings'
}; };
for (let period = 1; period <= t; period++) { for (let period = 1; period <= t; period++) {
@ -232,8 +247,9 @@ export class FireCalculatorComponent
r r
}); });
datasetPrincipal.data.push(principal.toNumber()); datasetDeposit.data.push(this.fireWealth);
datasetInterest.data.push(interest.toNumber()); datasetInterest.data.push(interest.toNumber());
datasetSavings.data.push(principal.minus(this.fireWealth).toNumber());
if (period === t) { if (period === t) {
this.projectedTotalAmount = totalAmount.toNumber(); this.projectedTotalAmount = totalAmount.toNumber();
@ -242,7 +258,7 @@ export class FireCalculatorComponent
return { return {
labels, labels,
datasets: [datasetPrincipal, datasetInterest] datasets: [datasetDeposit, datasetSavings, datasetInterest]
}; };
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.136.0", "version": "1.138.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {