mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
26 changed files with 1735 additions and 11 deletions
@ -0,0 +1,249 @@ |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|||
import { CreateOrUpdateJournalEntryDto } from '@ghostfolio/common/dtos'; |
|||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
JournalCalendarDataItem, |
|||
JournalResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Put, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { |
|||
endOfMonth, |
|||
format, |
|||
parseISO, |
|||
startOfMonth, |
|||
subMonths |
|||
} from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { JournalService } from './journal.service'; |
|||
|
|||
@Controller('journal') |
|||
export class JournalController { |
|||
public constructor( |
|||
private readonly journalService: JournalService, |
|||
private readonly orderService: OrderService, |
|||
private readonly portfolioService: PortfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readJournalEntry) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getJournal( |
|||
@Query('month') month: string, |
|||
@Query('year') year: string |
|||
): Promise<JournalResponse> { |
|||
const userId = this.request.user.id; |
|||
const monthNum = parseInt(month, 10) - 1; |
|||
const yearNum = parseInt(year, 10); |
|||
|
|||
if ( |
|||
isNaN(monthNum) || |
|||
isNaN(yearNum) || |
|||
monthNum < 0 || |
|||
monthNum > 11 || |
|||
yearNum < 1970 || |
|||
yearNum > 2100 |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
|
|||
const userCurrency = |
|||
this.request.user.settings?.settings?.baseCurrency ?? DEFAULT_CURRENCY; |
|||
|
|||
const startDate = startOfMonth(new Date(yearNum, monthNum)); |
|||
const endDate = endOfMonth(new Date(yearNum, monthNum)); |
|||
|
|||
// Fetch performance with a baseline that includes the previous month's
|
|||
// last day, so January deltas are computed correctly
|
|||
const performanceFetchStart = subMonths(startDate, 1); |
|||
const performanceYear = performanceFetchStart.getFullYear(); |
|||
|
|||
const [journalEntries, performanceResponse, activitiesResponse] = |
|||
await Promise.all([ |
|||
this.journalService.getJournalEntries({ |
|||
startDate, |
|||
endDate, |
|||
userId |
|||
}), |
|||
this.portfolioService.getPerformance({ |
|||
dateRange: |
|||
`${performanceYear}` === `${yearNum}` ? `${yearNum}` : 'max', |
|||
filters: [], |
|||
impersonationId: undefined, |
|||
userId |
|||
}), |
|||
this.orderService.getOrders({ |
|||
endDate, |
|||
userId, |
|||
userCurrency, |
|||
includeDrafts: false, |
|||
startDate: startDate, |
|||
withExcludedAccountsAndActivities: false |
|||
}) |
|||
]); |
|||
|
|||
const chart = performanceResponse?.chart ?? []; |
|||
const activities = activitiesResponse?.activities ?? []; |
|||
|
|||
const daysMap = new Map<string, JournalCalendarDataItem>(); |
|||
|
|||
// Build days from performance chart data
|
|||
for (const item of chart) { |
|||
const itemDate = parseISO(item.date); |
|||
|
|||
if (itemDate >= startDate && itemDate <= endDate) { |
|||
daysMap.set(item.date, { |
|||
activitiesCount: 0, |
|||
date: item.date, |
|||
hasNote: false, |
|||
netPerformance: item.netPerformance ?? 0, |
|||
netPerformanceInPercentage: item.netPerformanceInPercentage ?? 0, |
|||
realizedProfit: 0, |
|||
value: item.value ?? 0 |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// Calculate daily performance deltas (both absolute and percentage)
|
|||
const sortedDates = Array.from(daysMap.keys()).sort(); |
|||
let previousNetPerformance = 0; |
|||
let previousNetPerformanceInPercentage = 0; |
|||
|
|||
// Find the chart item just before our month starts to get the baseline
|
|||
for (const item of chart) { |
|||
const itemDate = parseISO(item.date); |
|||
if (itemDate < startDate) { |
|||
previousNetPerformance = item.netPerformance ?? 0; |
|||
previousNetPerformanceInPercentage = |
|||
item.netPerformanceInPercentage ?? 0; |
|||
} |
|||
} |
|||
|
|||
for (const dateKey of sortedDates) { |
|||
const day = daysMap.get(dateKey); |
|||
|
|||
const currentNetPerformance = day.netPerformance; |
|||
day.netPerformance = currentNetPerformance - previousNetPerformance; |
|||
previousNetPerformance = currentNetPerformance; |
|||
|
|||
const currentNetPerformanceInPercentage = day.netPerformanceInPercentage; |
|||
day.netPerformanceInPercentage = |
|||
currentNetPerformanceInPercentage - previousNetPerformanceInPercentage; |
|||
previousNetPerformanceInPercentage = currentNetPerformanceInPercentage; |
|||
} |
|||
|
|||
// Count activities per day and track dividend/interest income
|
|||
for (const activity of activities) { |
|||
const dateKey = format(activity.date, DATE_FORMAT); |
|||
let day = daysMap.get(dateKey); |
|||
|
|||
if (!day) { |
|||
day = { |
|||
activitiesCount: 0, |
|||
date: dateKey, |
|||
hasNote: false, |
|||
netPerformance: 0, |
|||
netPerformanceInPercentage: 0, |
|||
realizedProfit: 0, |
|||
value: 0 |
|||
}; |
|||
daysMap.set(dateKey, day); |
|||
} |
|||
|
|||
day.activitiesCount++; |
|||
|
|||
// Track dividend and interest income as realized profit.
|
|||
// SELL activities use gross proceeds (not actual realized gain),
|
|||
// so we exclude them to avoid misleading values.
|
|||
if (activity.type === 'DIVIDEND' || activity.type === 'INTEREST') { |
|||
day.realizedProfit += |
|||
activity.valueInBaseCurrency ?? activity.value ?? 0; |
|||
} |
|||
} |
|||
|
|||
// Mark days with journal notes
|
|||
for (const entry of journalEntries) { |
|||
const dateKey = format(entry.date, DATE_FORMAT); |
|||
const day = daysMap.get(dateKey); |
|||
|
|||
if (day) { |
|||
day.hasNote = true; |
|||
} |
|||
} |
|||
|
|||
const days = Array.from(daysMap.values()).sort((a, b) => |
|||
a.date.localeCompare(b.date) |
|||
); |
|||
|
|||
const stats = this.journalService.buildJournalStats(days); |
|||
|
|||
return { days, stats }; |
|||
} |
|||
|
|||
@Get(':date') |
|||
@HasPermission(permissions.readJournalEntry) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getJournalEntry(@Param('date') dateString: string) { |
|||
const userId = this.request.user.id; |
|||
const date = resetHours(parseISO(dateString)); |
|||
|
|||
return this.journalService.getJournalEntry({ date, userId }); |
|||
} |
|||
|
|||
@HasPermission(permissions.createJournalEntry) |
|||
@Put(':date') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createOrUpdateJournalEntry( |
|||
@Param('date') dateString: string, |
|||
@Body() data: CreateOrUpdateJournalEntryDto |
|||
) { |
|||
const userId = this.request.user.id; |
|||
|
|||
return this.journalService.createOrUpdateJournalEntry({ |
|||
date: dateString, |
|||
note: data.note, |
|||
userId |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.deleteJournalEntry) |
|||
@Delete(':date') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteJournalEntry(@Param('date') dateString: string) { |
|||
const userId = this.request.user.id; |
|||
const date = resetHours(parseISO(dateString)); |
|||
|
|||
try { |
|||
return await this.journalService.deleteJournalEntry({ date, userId }); |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { JournalController } from './journal.controller'; |
|||
import { JournalService } from './journal.service'; |
|||
|
|||
@Module({ |
|||
controllers: [JournalController], |
|||
exports: [JournalService], |
|||
imports: [OrderModule, PortfolioModule, PrismaModule], |
|||
providers: [JournalService] |
|||
}) |
|||
export class JournalModule {} |
|||
@ -0,0 +1,161 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
JournalCalendarDataItem, |
|||
JournalEntryItem, |
|||
JournalStats |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { JournalEntry } from '@prisma/client'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
|
|||
@Injectable() |
|||
export class JournalService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async getJournalEntry({ |
|||
date, |
|||
userId |
|||
}: { |
|||
date: Date; |
|||
userId: string; |
|||
}): Promise<JournalEntryItem | null> { |
|||
const entry = await this.prismaService.journalEntry.findUnique({ |
|||
where: { |
|||
userId_date: { |
|||
userId, |
|||
date: resetHours(date) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
if (!entry) { |
|||
return null; |
|||
} |
|||
|
|||
return { |
|||
date: format(entry.date, DATE_FORMAT), |
|||
note: entry.note |
|||
}; |
|||
} |
|||
|
|||
public async getJournalEntries({ |
|||
startDate, |
|||
endDate, |
|||
userId |
|||
}: { |
|||
startDate: Date; |
|||
endDate: Date; |
|||
userId: string; |
|||
}): Promise<JournalEntry[]> { |
|||
return this.prismaService.journalEntry.findMany({ |
|||
where: { |
|||
userId, |
|||
date: { |
|||
gte: resetHours(startDate), |
|||
lte: resetHours(endDate) |
|||
} |
|||
}, |
|||
orderBy: { |
|||
date: 'asc' |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async createOrUpdateJournalEntry({ |
|||
date, |
|||
note, |
|||
userId |
|||
}: { |
|||
date: string; |
|||
note: string; |
|||
userId: string; |
|||
}): Promise<JournalEntry> { |
|||
const parsedDate = resetHours(parseISO(date)); |
|||
|
|||
return this.prismaService.journalEntry.upsert({ |
|||
create: { |
|||
date: parsedDate, |
|||
note, |
|||
user: { |
|||
connect: { id: userId } |
|||
} |
|||
}, |
|||
update: { |
|||
note |
|||
}, |
|||
where: { |
|||
userId_date: { |
|||
userId, |
|||
date: parsedDate |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async deleteJournalEntry({ |
|||
date, |
|||
userId |
|||
}: { |
|||
date: Date; |
|||
userId: string; |
|||
}): Promise<JournalEntry> { |
|||
return this.prismaService.journalEntry.delete({ |
|||
where: { |
|||
userId_date: { |
|||
userId, |
|||
date: resetHours(date) |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public buildJournalStats(days: JournalCalendarDataItem[]): JournalStats { |
|||
const tradingDays = days.filter( |
|||
(d) => d.activitiesCount > 0 || d.netPerformance !== 0 |
|||
); |
|||
const winningDays = tradingDays.filter((d) => d.netPerformance > 0); |
|||
const losingDays = tradingDays.filter((d) => d.netPerformance < 0); |
|||
|
|||
const profits = tradingDays.map((d) => d.netPerformance); |
|||
const largestProfit = profits.length > 0 ? Math.max(...profits) : 0; |
|||
const largestLoss = profits.length > 0 ? Math.min(...profits) : 0; |
|||
|
|||
const averageDailyPnL = |
|||
tradingDays.length > 0 |
|||
? profits.reduce((sum, val) => sum + val, 0) / tradingDays.length |
|||
: 0; |
|||
|
|||
let maxConsecutiveWins = 0; |
|||
let maxConsecutiveLosses = 0; |
|||
let currentWins = 0; |
|||
let currentLosses = 0; |
|||
|
|||
for (const day of tradingDays) { |
|||
if (day.netPerformance > 0) { |
|||
currentWins++; |
|||
currentLosses = 0; |
|||
maxConsecutiveWins = Math.max(maxConsecutiveWins, currentWins); |
|||
} else if (day.netPerformance < 0) { |
|||
currentLosses++; |
|||
currentWins = 0; |
|||
maxConsecutiveLosses = Math.max(maxConsecutiveLosses, currentLosses); |
|||
} else { |
|||
currentWins = 0; |
|||
currentLosses = 0; |
|||
} |
|||
} |
|||
|
|||
return { |
|||
averageDailyPnL, |
|||
largestLoss, |
|||
largestProfit, |
|||
losingDays: losingDays.length, |
|||
maxConsecutiveLosses, |
|||
maxConsecutiveWins, |
|||
totalTradingDays: tradingDays.length, |
|||
winningDays: winningDays.length |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,123 @@ |
|||
<gf-dialog-header |
|||
[deviceType]="data.deviceType" |
|||
[title]="dateLabel" |
|||
(closeButtonClicked)="onClose()" |
|||
/> |
|||
|
|||
<div class="flex-grow-1 overflow-auto px-3" mat-dialog-content> |
|||
<!-- Performance summary --> |
|||
<div class="d-flex flex-wrap gap-4 mb-4"> |
|||
<div class="performance-metric"> |
|||
<div class="metric-label" i18n>Portfolio Change</div> |
|||
<gf-value |
|||
[colorizeSign]="true" |
|||
[isCurrency]="true" |
|||
[locale]="data.locale" |
|||
[unit]="data.baseCurrency" |
|||
[value]="data.netPerformance" |
|||
/> |
|||
</div> |
|||
|
|||
@if (data.realizedProfit !== 0) { |
|||
<div class="performance-metric"> |
|||
<div class="metric-label" i18n>Dividend & Interest</div> |
|||
<gf-value |
|||
[colorizeSign]="true" |
|||
[isCurrency]="true" |
|||
[locale]="data.locale" |
|||
[unit]="data.baseCurrency" |
|||
[value]="data.realizedProfit" |
|||
/> |
|||
</div> |
|||
} |
|||
|
|||
<div class="performance-metric"> |
|||
<div class="metric-label" i18n>Activities</div> |
|||
<gf-value [value]="data.activitiesCount" /> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Activities list --> |
|||
@if (activities.length > 0) { |
|||
<h4 class="mb-2" i18n>Activities</h4> |
|||
<div class="activities-list mb-4"> |
|||
<table class="w-100"> |
|||
<thead> |
|||
<tr> |
|||
<th i18n>Type</th> |
|||
<th i18n>Symbol</th> |
|||
<th class="text-end" i18n>Quantity</th> |
|||
<th class="text-end" i18n>Unit Price</th> |
|||
<th class="text-end" i18n>Value</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
@for (activity of activities; track activity.id) { |
|||
<tr> |
|||
<td> |
|||
<span |
|||
class="activity-type" |
|||
[ngClass]="activity.type?.toLowerCase()" |
|||
> |
|||
{{ activity.type }} |
|||
</span> |
|||
</td> |
|||
<td>{{ activity.SymbolProfile?.symbol ?? '-' }}</td> |
|||
<td class="text-end">{{ activity.quantity | number }}</td> |
|||
<td class="text-end"> |
|||
<gf-value |
|||
size="small" |
|||
[isCurrency]="true" |
|||
[locale]="data.locale" |
|||
[unit]="activity.SymbolProfile?.currency" |
|||
[value]="activity.unitPrice" |
|||
/> |
|||
</td> |
|||
<td class="text-end"> |
|||
<gf-value |
|||
size="small" |
|||
[isCurrency]="true" |
|||
[locale]="data.locale" |
|||
[unit]="activity.SymbolProfile?.currency" |
|||
[value]="activity.value" |
|||
/> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Journal Note --> |
|||
<h4 class="mb-2" i18n>Journal Note</h4> |
|||
<mat-form-field appearance="outline" class="w-100"> |
|||
<mat-label i18n |
|||
>Write your thoughts, observations, or strategy notes for this |
|||
day...</mat-label |
|||
> |
|||
<textarea |
|||
matInput |
|||
rows="6" |
|||
[disabled]="isLoadingNote" |
|||
[(ngModel)]="note" |
|||
></textarea> |
|||
</mat-form-field> |
|||
|
|||
<div class="d-flex justify-content-end mb-3"> |
|||
<button |
|||
color="primary" |
|||
i18n |
|||
mat-flat-button |
|||
[disabled]="isSavingNote || isLoadingNote" |
|||
(click)="onSaveNote()" |
|||
> |
|||
Save Note |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<gf-dialog-footer |
|||
[deviceType]="data.deviceType" |
|||
(closeButtonClicked)="onClose()" |
|||
/> |
|||
@ -0,0 +1,97 @@ |
|||
:host { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
} |
|||
|
|||
.performance-metric { |
|||
.metric-label { |
|||
font-size: 0.75rem; |
|||
opacity: 0.7; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
margin-bottom: 0.25rem; |
|||
} |
|||
} |
|||
|
|||
.activities-list { |
|||
table { |
|||
border-collapse: collapse; |
|||
|
|||
th { |
|||
font-size: 0.75rem; |
|||
font-weight: 500; |
|||
opacity: 0.7; |
|||
padding: 0.5rem 0.75rem; |
|||
text-transform: uppercase; |
|||
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.12)); |
|||
} |
|||
|
|||
td { |
|||
padding: 0.5rem 0.75rem; |
|||
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.06)); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.activity-type { |
|||
display: inline-block; |
|||
padding: 0.15rem 0.5rem; |
|||
border-radius: 4px; |
|||
font-size: 0.7rem; |
|||
font-weight: 600; |
|||
text-transform: uppercase; |
|||
|
|||
&.buy { |
|||
background-color: rgba(34, 197, 94, 0.1); |
|||
color: #22c55e; |
|||
} |
|||
|
|||
&.sell { |
|||
background-color: rgba(239, 68, 68, 0.1); |
|||
color: #ef4444; |
|||
} |
|||
|
|||
&.dividend, |
|||
&.interest { |
|||
background-color: rgba(59, 130, 246, 0.1); |
|||
color: #3b82f6; |
|||
} |
|||
|
|||
&.fee { |
|||
background-color: rgba(249, 115, 22, 0.1); |
|||
color: #f97316; |
|||
} |
|||
} |
|||
|
|||
// Dark mode support |
|||
:host-context(.is-dark-theme) { |
|||
.activities-list { |
|||
table { |
|||
--border-color: rgba(255, 255, 255, 0.12); |
|||
|
|||
td { |
|||
--border-color: rgba(255, 255, 255, 0.06); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.activity-type { |
|||
&.buy { |
|||
background-color: rgba(34, 197, 94, 0.2); |
|||
} |
|||
|
|||
&.sell { |
|||
background-color: rgba(239, 68, 68, 0.2); |
|||
} |
|||
|
|||
&.dividend, |
|||
&.interest { |
|||
background-color: rgba(59, 130, 246, 0.2); |
|||
} |
|||
|
|||
&.fee { |
|||
background-color: rgba(249, 115, 22, 0.2); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,214 @@ |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; |
|||
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
import { GfValueComponent } from '@ghostfolio/ui/value'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
Inject, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { |
|||
MAT_DIALOG_DATA, |
|||
MatDialogModule, |
|||
MatDialogRef |
|||
} from '@angular/material/dialog'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatInputModule } from '@angular/material/input'; |
|||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
export interface JournalDayDialogData { |
|||
activitiesCount: number; |
|||
baseCurrency: string; |
|||
date: string; |
|||
deviceType: string; |
|||
locale: string; |
|||
netPerformance: number; |
|||
realizedProfit: number; |
|||
} |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
host: { class: 'd-flex flex-column h-100' }, |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
GfDialogFooterComponent, |
|||
GfDialogHeaderComponent, |
|||
GfValueComponent, |
|||
MatButtonModule, |
|||
MatDialogModule, |
|||
MatFormFieldModule, |
|||
MatInputModule, |
|||
MatSnackBarModule |
|||
], |
|||
selector: 'gf-journal-day-dialog', |
|||
styleUrls: ['./journal-day-dialog.component.scss'], |
|||
templateUrl: './journal-day-dialog.component.html' |
|||
}) |
|||
export class GfJournalDayDialogComponent implements OnInit, OnDestroy { |
|||
public activities: Activity[] = []; |
|||
public date: string; |
|||
public dateLabel: string; |
|||
public hasChanges = false; |
|||
public isLoadingActivities = true; |
|||
public isLoadingNote = true; |
|||
public isSavingNote = false; |
|||
public note = ''; |
|||
|
|||
private originalNote = ''; |
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
@Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData, |
|||
private dataService: DataService, |
|||
public dialogRef: MatDialogRef<GfJournalDayDialogComponent>, |
|||
private snackBar: MatSnackBar |
|||
) {} |
|||
|
|||
public ngOnInit() { |
|||
this.date = this.data.date; |
|||
this.dateLabel = new Intl.DateTimeFormat(this.data.locale ?? 'en-US', { |
|||
weekday: 'long', |
|||
year: 'numeric', |
|||
month: 'long', |
|||
day: 'numeric' |
|||
}).format(parseISO(this.date)); |
|||
|
|||
this.loadActivities(); |
|||
this.loadNote(); |
|||
} |
|||
|
|||
public onSaveNote() { |
|||
if (this.isSavingNote) { |
|||
return; |
|||
} |
|||
|
|||
this.isSavingNote = true; |
|||
|
|||
if (this.note?.trim()) { |
|||
this.dataService |
|||
.putJournalEntry({ date: this.date, note: this.note.trim() }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.hasChanges = true; |
|||
this.originalNote = this.note.trim(); |
|||
this.isSavingNote = false; |
|||
this.snackBar.open($localize`Note saved`, undefined, { |
|||
duration: 3000 |
|||
}); |
|||
this.changeDetectorRef.markForCheck(); |
|||
}, |
|||
error: () => { |
|||
this.isSavingNote = false; |
|||
this.snackBar.open($localize`Failed to save note`, undefined, { |
|||
duration: 5000 |
|||
}); |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} else if (this.originalNote) { |
|||
// Only call delete if there was an existing note
|
|||
this.dataService |
|||
.deleteJournalEntry({ date: this.date }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.hasChanges = true; |
|||
this.originalNote = ''; |
|||
this.isSavingNote = false; |
|||
this.snackBar.open($localize`Note deleted`, undefined, { |
|||
duration: 3000 |
|||
}); |
|||
this.changeDetectorRef.markForCheck(); |
|||
}, |
|||
error: () => { |
|||
this.isSavingNote = false; |
|||
this.snackBar.open($localize`Failed to delete note`, undefined, { |
|||
duration: 5000 |
|||
}); |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} else { |
|||
// No existing note and empty input - nothing to do
|
|||
this.isSavingNote = false; |
|||
} |
|||
} |
|||
|
|||
public onClose() { |
|||
this.dialogRef.close({ hasChanges: this.hasChanges }); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private loadActivities() { |
|||
this.isLoadingActivities = true; |
|||
|
|||
// Use the year of the selected date as range filter to avoid
|
|||
// fetching all-time activities and missing older dates
|
|||
const year = this.date.substring(0, 4); |
|||
|
|||
this.dataService |
|||
.fetchActivities({ |
|||
filters: [], |
|||
range: year as any, |
|||
skip: 0, |
|||
take: 500, |
|||
sortColumn: 'date', |
|||
sortDirection: 'desc' |
|||
}) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(({ activities }) => { |
|||
this.activities = activities.filter((activity) => { |
|||
const activityDate = format( |
|||
typeof activity.date === 'string' |
|||
? parseISO(activity.date as string) |
|||
: activity.date, |
|||
DATE_FORMAT |
|||
); |
|||
return activityDate === this.date; |
|||
}); |
|||
this.isLoadingActivities = false; |
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
|
|||
private loadNote() { |
|||
this.isLoadingNote = true; |
|||
|
|||
this.dataService |
|||
.fetchJournalEntry({ date: this.date }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: (entry) => { |
|||
this.note = entry?.note ?? ''; |
|||
this.originalNote = this.note; |
|||
this.isLoadingNote = false; |
|||
this.changeDetectorRef.markForCheck(); |
|||
}, |
|||
error: () => { |
|||
this.note = ''; |
|||
this.originalNote = ''; |
|||
this.isLoadingNote = false; |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { |
|||
JournalCalendarDataItem, |
|||
JournalStats, |
|||
User |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { GfJournalCalendarComponent } from '@ghostfolio/ui/journal-calendar'; |
|||
import { DataService } from '@ghostfolio/ui/services'; |
|||
|
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
import { |
|||
GfJournalDayDialogComponent, |
|||
JournalDayDialogData |
|||
} from './journal-day-dialog/journal-day-dialog.component'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [GfJournalCalendarComponent, NgxSkeletonLoaderModule], |
|||
selector: 'gf-journal-page', |
|||
styleUrls: ['./journal-page.scss'], |
|||
templateUrl: './journal-page.html' |
|||
}) |
|||
export class JournalPageComponent implements OnInit, OnDestroy { |
|||
public days: JournalCalendarDataItem[] = []; |
|||
public deviceType: string; |
|||
public isLoading = true; |
|||
public month: number; |
|||
public stats: JournalStats; |
|||
public user: User; |
|||
public year: number; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private dialog: MatDialog, |
|||
private userService: UserService |
|||
) { |
|||
const now = new Date(); |
|||
this.month = now.getMonth() + 1; |
|||
this.year = now.getFullYear(); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
this.loadJournalData(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onMonthChanged({ month, year }: { month: number; year: number }) { |
|||
this.month = month; |
|||
this.year = year; |
|||
this.loadJournalData(); |
|||
} |
|||
|
|||
public onDayClicked(date: string) { |
|||
const dayData = this.days.find((d) => d.date === date); |
|||
|
|||
const dialogRef = this.dialog.open(GfJournalDayDialogComponent, { |
|||
autoFocus: false, |
|||
data: { |
|||
activitiesCount: dayData?.activitiesCount ?? 0, |
|||
baseCurrency: this.user?.settings?.baseCurrency ?? 'USD', |
|||
date, |
|||
deviceType: this.deviceType, |
|||
locale: this.user?.settings?.locale, |
|||
netPerformance: dayData?.netPerformance ?? 0, |
|||
realizedProfit: dayData?.realizedProfit ?? 0 |
|||
} as JournalDayDialogData, |
|||
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
|||
maxWidth: this.deviceType === 'mobile' ? '100vw' : '50rem', |
|||
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((result) => { |
|||
if (result?.hasChanges) { |
|||
this.loadJournalData(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private loadJournalData() { |
|||
this.isLoading = true; |
|||
this.changeDetectorRef.markForCheck(); |
|||
|
|||
this.dataService |
|||
.fetchJournal({ month: this.month, year: this.year }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: (response) => { |
|||
this.days = response.days; |
|||
this.stats = response.stats; |
|||
this.isLoading = false; |
|||
this.changeDetectorRef.markForCheck(); |
|||
}, |
|||
error: () => { |
|||
this.days = []; |
|||
this.stats = undefined; |
|||
this.isLoading = false; |
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
@if (isLoading) { |
|||
<div class="p-3"> |
|||
<ngx-skeleton-loader |
|||
animation="pulse" |
|||
[theme]="{ height: '400px', width: '100%' }" |
|||
/> |
|||
</div> |
|||
} @else { |
|||
<div class="container p-3"> |
|||
<gf-journal-calendar |
|||
[baseCurrency]="user?.settings?.baseCurrency" |
|||
[days]="days" |
|||
[locale]="user?.settings?.locale" |
|||
[month]="month" |
|||
[stats]="stats" |
|||
[year]="year" |
|||
(dayClicked)="onDayClicked($event)" |
|||
(monthChanged)="onMonthChanged($event)" |
|||
/> |
|||
</div> |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
|||
|
|||
import { Routes } from '@angular/router'; |
|||
|
|||
import { JournalPageComponent } from './journal-page.component'; |
|||
|
|||
export const routes: Routes = [ |
|||
{ |
|||
component: JournalPageComponent, |
|||
path: '', |
|||
title: internalRoutes.portfolio.subRoutes.journal.title |
|||
} |
|||
]; |
|||
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; |
|||
|
|||
export class CreateOrUpdateJournalEntryDto { |
|||
@IsNotEmpty() |
|||
@IsString() |
|||
@MaxLength(10000) |
|||
note: string; |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
export interface JournalEntryItem { |
|||
date: string; |
|||
note?: string; |
|||
} |
|||
|
|||
export interface JournalCalendarDataItem { |
|||
activitiesCount: number; |
|||
date: string; |
|||
hasNote: boolean; |
|||
netPerformance: number; |
|||
netPerformanceInPercentage: number; |
|||
realizedProfit: number; |
|||
value: number; |
|||
} |
|||
|
|||
export interface JournalResponse { |
|||
days: JournalCalendarDataItem[]; |
|||
stats: JournalStats; |
|||
} |
|||
|
|||
export interface JournalStats { |
|||
averageDailyPnL: number; |
|||
largestLoss: number; |
|||
largestProfit: number; |
|||
losingDays: number; |
|||
maxConsecutiveLosses: number; |
|||
maxConsecutiveWins: number; |
|||
totalTradingDays: number; |
|||
winningDays: number; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './journal-calendar.component'; |
|||
@ -0,0 +1,129 @@ |
|||
<div class="journal-calendar"> |
|||
<!-- Header with navigation --> |
|||
<div |
|||
class="calendar-header d-flex align-items-center justify-content-between mb-3" |
|||
> |
|||
<button mat-icon-button (click)="onPreviousMonth()"> |
|||
<ion-icon name="chevron-back-outline" /> |
|||
</button> |
|||
<h3 class="mb-0">{{ monthLabel }}</h3> |
|||
<button mat-icon-button (click)="onNextMonth()"> |
|||
<ion-icon name="chevron-forward-outline" /> |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- Stats bar --> |
|||
@if (stats) { |
|||
<div class="stats-bar d-flex flex-wrap gap-3 mb-3"> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value">{{ stats.totalTradingDays }}</div> |
|||
<div class="stat-label" i18n>Trading Days</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-success">{{ stats.winningDays }}</div> |
|||
<div class="stat-label" i18n>Winning Days</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-danger">{{ stats.losingDays }}</div> |
|||
<div class="stat-label" i18n>Losing Days</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value">{{ winRate }}%</div> |
|||
<div class="stat-label" i18n>Win Rate</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value"> |
|||
{{ formatCurrency(stats.averageDailyPnL) }} |
|||
</div> |
|||
<div class="stat-label" i18n>Avg Daily P&L</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
|
|||
<!-- Additional stats --> |
|||
<div class="stats-bar d-flex flex-wrap gap-3 mb-3"> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-success"> |
|||
{{ formatCurrency(stats.largestProfit) }} |
|||
</div> |
|||
<div class="stat-label" i18n>Largest Profit</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-danger"> |
|||
{{ formatCurrency(stats.largestLoss) }} |
|||
</div> |
|||
<div class="stat-label" i18n>Largest Loss</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-success"> |
|||
{{ stats.maxConsecutiveWins }} |
|||
</div> |
|||
<div class="stat-label" i18n>Max Consecutive Wins</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
<mat-card class="stat-card flex-fill"> |
|||
<mat-card-content> |
|||
<div class="stat-value text-danger"> |
|||
{{ stats.maxConsecutiveLosses }} |
|||
</div> |
|||
<div class="stat-label" i18n>Max Consecutive Losses</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Calendar grid --> |
|||
<div class="calendar-grid"> |
|||
<div class="calendar-week-header d-flex"> |
|||
@for (weekDay of weekDays; track weekDay) { |
|||
<div class="calendar-cell header-cell">{{ weekDay }}</div> |
|||
} |
|||
</div> |
|||
|
|||
@for (week of calendarWeeks; track $index) { |
|||
<div class="calendar-week d-flex"> |
|||
@for (day of week; track day.date) { |
|||
<div |
|||
class="calendar-cell day-cell" |
|||
[class.clickable]="day.isCurrentMonth" |
|||
[class.today]="day.isToday" |
|||
[ngClass]="getDayClass(day)" |
|||
(click)="onDayClick(day)" |
|||
> |
|||
<div class="day-number">{{ day.dayOfMonth }}</div> |
|||
@if (day.data && day.isCurrentMonth) { |
|||
<div class="day-pnl"> |
|||
{{ formatCurrency(day.data.netPerformance) }} |
|||
</div> |
|||
@if (day.data.activitiesCount > 0) { |
|||
<div class="day-activities"> |
|||
<span i18n>{day.data.activitiesCount, plural, |
|||
=1 {1 activity} |
|||
other {{{day.data.activitiesCount}} activities} |
|||
}</span> |
|||
</div> |
|||
} |
|||
@if (day.data.hasNote) { |
|||
<ion-icon class="note-icon" name="document-text-outline" /> |
|||
} |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,185 @@ |
|||
:host { |
|||
display: block; |
|||
} |
|||
|
|||
.journal-calendar { |
|||
max-width: 960px; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
.calendar-header { |
|||
h3 { |
|||
font-size: 1.25rem; |
|||
font-weight: 500; |
|||
min-width: 200px; |
|||
text-align: center; |
|||
} |
|||
} |
|||
|
|||
.stats-bar { |
|||
.stat-card { |
|||
min-width: 120px; |
|||
text-align: center; |
|||
|
|||
mat-card-content { |
|||
padding: 0.75rem 0.5rem; |
|||
} |
|||
|
|||
.stat-value { |
|||
font-size: 1.25rem; |
|||
font-weight: 600; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
.stat-label { |
|||
font-size: 0.75rem; |
|||
opacity: 0.7; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.text-success { |
|||
color: #22c55e; |
|||
} |
|||
|
|||
.text-danger { |
|||
color: #ef4444; |
|||
} |
|||
|
|||
.calendar-grid { |
|||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.calendar-cell { |
|||
flex: 1; |
|||
min-height: 40px; |
|||
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); |
|||
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); |
|||
|
|||
&:last-child { |
|||
border-right: none; |
|||
} |
|||
} |
|||
|
|||
.header-cell { |
|||
background: var(--header-bg, rgba(0, 0, 0, 0.04)); |
|||
font-weight: 500; |
|||
font-size: 0.8rem; |
|||
text-align: center; |
|||
padding: 0.5rem 0; |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.day-cell { |
|||
min-height: 90px; |
|||
padding: 0.25rem 0.4rem; |
|||
position: relative; |
|||
transition: background-color 0.15s ease; |
|||
|
|||
&.clickable { |
|||
cursor: pointer; |
|||
|
|||
&:hover { |
|||
background-color: var(--hover-bg, rgba(0, 0, 0, 0.04)); |
|||
} |
|||
} |
|||
|
|||
&.other-month { |
|||
opacity: 0.3; |
|||
} |
|||
|
|||
&.today { |
|||
.day-number { |
|||
background-color: var(--primary-color, #3f51b5); |
|||
color: white; |
|||
border-radius: 50%; |
|||
width: 24px; |
|||
height: 24px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
} |
|||
|
|||
&.positive { |
|||
background-color: rgba(34, 197, 94, 0.08); |
|||
|
|||
.day-pnl { |
|||
color: #22c55e; |
|||
} |
|||
} |
|||
|
|||
&.negative { |
|||
background-color: rgba(239, 68, 68, 0.08); |
|||
|
|||
.day-pnl { |
|||
color: #ef4444; |
|||
} |
|||
} |
|||
|
|||
&.neutral { |
|||
background-color: rgba(156, 163, 175, 0.06); |
|||
|
|||
.day-pnl { |
|||
color: rgba(156, 163, 175, 0.8); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.day-number { |
|||
font-size: 0.8rem; |
|||
font-weight: 500; |
|||
margin-bottom: 0.15rem; |
|||
} |
|||
|
|||
.day-pnl { |
|||
font-size: 0.75rem; |
|||
font-weight: 600; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.day-activities { |
|||
font-size: 0.65rem; |
|||
opacity: 0.7; |
|||
margin-top: 0.1rem; |
|||
} |
|||
|
|||
.note-icon { |
|||
position: absolute; |
|||
bottom: 0.25rem; |
|||
right: 0.25rem; |
|||
font-size: 0.75rem; |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.calendar-week { |
|||
&:last-child { |
|||
.calendar-cell { |
|||
border-bottom: none; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Dark mode support |
|||
:host-context(.is-dark-theme) { |
|||
.calendar-grid { |
|||
--border-color: rgba(255, 255, 255, 0.12); |
|||
} |
|||
|
|||
.header-cell { |
|||
--header-bg: rgba(255, 255, 255, 0.05); |
|||
} |
|||
|
|||
.day-cell { |
|||
&.clickable:hover { |
|||
--hover-bg: rgba(255, 255, 255, 0.05); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,238 @@ |
|||
import { |
|||
JournalCalendarDataItem, |
|||
JournalStats |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
EventEmitter, |
|||
Input, |
|||
OnChanges, |
|||
Output, |
|||
SimpleChanges |
|||
} from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { IonIcon } from '@ionic/angular/standalone'; |
|||
import { |
|||
addMonths, |
|||
eachDayOfInterval, |
|||
endOfMonth, |
|||
format, |
|||
getDay, |
|||
isToday, |
|||
startOfMonth, |
|||
subMonths |
|||
} from 'date-fns'; |
|||
import { addIcons } from 'ionicons'; |
|||
import { |
|||
chevronBackOutline, |
|||
chevronForwardOutline, |
|||
documentTextOutline |
|||
} from 'ionicons/icons'; |
|||
|
|||
export interface CalendarDay { |
|||
data?: JournalCalendarDataItem; |
|||
date: Date; |
|||
dayOfMonth: number; |
|||
isCurrentMonth: boolean; |
|||
isToday: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [CommonModule, IonIcon, MatButtonModule, MatCardModule], |
|||
selector: 'gf-journal-calendar', |
|||
styleUrls: ['./journal-calendar.component.scss'], |
|||
templateUrl: './journal-calendar.component.html' |
|||
}) |
|||
export class GfJournalCalendarComponent implements OnChanges { |
|||
@Input() baseCurrency: string; |
|||
@Input() days: JournalCalendarDataItem[] = []; |
|||
@Input() locale: string; |
|||
@Input() month: number; |
|||
@Input() stats: JournalStats; |
|||
@Input() year: number; |
|||
|
|||
@Output() dayClicked = new EventEmitter<string>(); |
|||
@Output() monthChanged = new EventEmitter<{ month: number; year: number }>(); |
|||
|
|||
public calendarWeeks: CalendarDay[][] = []; |
|||
public weekDays: string[] = []; |
|||
|
|||
public get monthLabel(): string { |
|||
return new Intl.DateTimeFormat(this.locale ?? 'en-US', { |
|||
month: 'long', |
|||
year: 'numeric' |
|||
}).format(new Date(this.year, this.month - 1)); |
|||
} |
|||
|
|||
public get winRate(): number { |
|||
if (!this.stats || this.stats.totalTradingDays === 0) { |
|||
return 0; |
|||
} |
|||
|
|||
return Math.round( |
|||
(this.stats.winningDays / this.stats.totalTradingDays) * 100 |
|||
); |
|||
} |
|||
|
|||
public constructor() { |
|||
addIcons({ |
|||
chevronBackOutline, |
|||
chevronForwardOutline, |
|||
documentTextOutline |
|||
}); |
|||
} |
|||
|
|||
public ngOnChanges(changes: SimpleChanges) { |
|||
if (changes['days'] || changes['month'] || changes['year']) { |
|||
this.buildCalendar(); |
|||
} |
|||
|
|||
if (changes['locale']) { |
|||
this.buildWeekDays(); |
|||
} |
|||
} |
|||
|
|||
public onPreviousMonth() { |
|||
const prev = subMonths(new Date(this.year, this.month - 1), 1); |
|||
this.monthChanged.emit({ |
|||
month: prev.getMonth() + 1, |
|||
year: prev.getFullYear() |
|||
}); |
|||
} |
|||
|
|||
public onNextMonth() { |
|||
const next = addMonths(new Date(this.year, this.month - 1), 1); |
|||
this.monthChanged.emit({ |
|||
month: next.getMonth() + 1, |
|||
year: next.getFullYear() |
|||
}); |
|||
} |
|||
|
|||
public onDayClick(day: CalendarDay) { |
|||
if (day.isCurrentMonth) { |
|||
this.dayClicked.emit(format(day.date, 'yyyy-MM-dd')); |
|||
} |
|||
} |
|||
|
|||
public getDayClass(day: CalendarDay): string { |
|||
if (!day.isCurrentMonth) { |
|||
return 'other-month'; |
|||
} |
|||
|
|||
if (!day.data) { |
|||
return ''; |
|||
} |
|||
|
|||
if (day.data.netPerformance > 0) { |
|||
return 'positive'; |
|||
} |
|||
|
|||
if (day.data.netPerformance < 0) { |
|||
return 'negative'; |
|||
} |
|||
|
|||
return 'neutral'; |
|||
} |
|||
|
|||
public formatCurrency(value: number): string { |
|||
if (value === undefined || value === null) { |
|||
return ''; |
|||
} |
|||
|
|||
try { |
|||
return new Intl.NumberFormat(this.locale ?? 'en-US', { |
|||
style: 'currency', |
|||
currency: this.baseCurrency ?? 'USD', |
|||
minimumFractionDigits: 0, |
|||
maximumFractionDigits: 0 |
|||
}).format(value); |
|||
} catch { |
|||
return value.toFixed(0); |
|||
} |
|||
} |
|||
|
|||
private buildWeekDays() { |
|||
// Generate locale-aware weekday names (Sunday-start)
|
|||
const baseDate = new Date(2024, 0, 7); // A known Sunday
|
|||
|
|||
this.weekDays = Array.from({ length: 7 }, (_, i) => { |
|||
const day = new Date(baseDate); |
|||
day.setDate(baseDate.getDate() + i); |
|||
|
|||
return new Intl.DateTimeFormat(this.locale ?? 'en-US', { |
|||
weekday: 'short' |
|||
}).format(day); |
|||
}); |
|||
} |
|||
|
|||
private buildCalendar() { |
|||
if (!this.weekDays.length) { |
|||
this.buildWeekDays(); |
|||
} |
|||
|
|||
const currentDate = new Date(this.year, this.month - 1); |
|||
const monthStart = startOfMonth(currentDate); |
|||
const monthEnd = endOfMonth(currentDate); |
|||
|
|||
const daysDataMap = new Map<string, JournalCalendarDataItem>(); |
|||
|
|||
for (const day of this.days) { |
|||
daysDataMap.set(day.date, day); |
|||
} |
|||
|
|||
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd }); |
|||
const startDayOfWeek = getDay(monthStart); |
|||
|
|||
const calendarDays: CalendarDay[] = []; |
|||
|
|||
// Padding for days before the month starts
|
|||
for (let i = 0; i < startDayOfWeek; i++) { |
|||
const date = new Date(this.year, this.month - 1, -startDayOfWeek + i + 1); |
|||
calendarDays.push({ |
|||
date, |
|||
dayOfMonth: date.getDate(), |
|||
isCurrentMonth: false, |
|||
isToday: false |
|||
}); |
|||
} |
|||
|
|||
// Actual days
|
|||
for (const date of daysInMonth) { |
|||
const dateKey = format(date, 'yyyy-MM-dd'); |
|||
calendarDays.push({ |
|||
data: daysDataMap.get(dateKey), |
|||
date, |
|||
dayOfMonth: date.getDate(), |
|||
isCurrentMonth: true, |
|||
isToday: isToday(date) |
|||
}); |
|||
} |
|||
|
|||
// Pad to complete the last week
|
|||
while (calendarDays.length % 7 !== 0) { |
|||
const lastDate = calendarDays[calendarDays.length - 1].date; |
|||
const date = new Date( |
|||
lastDate.getFullYear(), |
|||
lastDate.getMonth(), |
|||
lastDate.getDate() + 1 |
|||
); |
|||
calendarDays.push({ |
|||
date, |
|||
dayOfMonth: date.getDate(), |
|||
isCurrentMonth: false, |
|||
isToday: false |
|||
}); |
|||
} |
|||
|
|||
// Group into weeks
|
|||
this.calendarWeeks = []; |
|||
for (let i = 0; i < calendarDays.length; i += 7) { |
|||
this.calendarWeeks.push(calendarDays.slice(i, i + 7)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
-- CreateTable |
|||
CREATE TABLE "JournalEntry" ( |
|||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
"date" TIMESTAMP(3) NOT NULL, |
|||
"id" TEXT NOT NULL, |
|||
"note" TEXT NOT NULL, |
|||
"updatedAt" TIMESTAMP(3) NOT NULL, |
|||
"userId" TEXT NOT NULL, |
|||
|
|||
CONSTRAINT "JournalEntry_pkey" PRIMARY KEY ("id") |
|||
); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "JournalEntry_date_idx" ON "JournalEntry"("date"); |
|||
|
|||
-- CreateIndex |
|||
CREATE INDEX "JournalEntry_userId_idx" ON "JournalEntry"("userId"); |
|||
|
|||
-- CreateIndex |
|||
CREATE UNIQUE INDEX "JournalEntry_userId_date_key" ON "JournalEntry"("userId", "date"); |
|||
|
|||
-- AddForeignKey |
|||
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
|||
Loading…
Reference in new issue