diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 89f52e1ea..ff490c901 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -47,6 +47,7 @@ import { ExportModule } from './export/export.module'; import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; +import { JournalModule } from './journal/journal.module'; import { LogoModule } from './logo/logo.module'; import { OrderModule } from './order/order.module'; import { PlatformModule } from './platform/platform.module'; @@ -92,6 +93,7 @@ import { UserModule } from './user/user.module'; HealthModule, ImportModule, InfoModule, + JournalModule, LogoModule, MarketDataModule, OrderModule, diff --git a/apps/api/src/app/journal/journal.controller.ts b/apps/api/src/app/journal/journal.controller.ts new file mode 100644 index 000000000..91e9d5134 --- /dev/null +++ b/apps/api/src/app/journal/journal.controller.ts @@ -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 { + 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(); + + // 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 + ); + } + } +} diff --git a/apps/api/src/app/journal/journal.module.ts b/apps/api/src/app/journal/journal.module.ts new file mode 100644 index 000000000..efde8cbd5 --- /dev/null +++ b/apps/api/src/app/journal/journal.module.ts @@ -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 {} diff --git a/apps/api/src/app/journal/journal.service.ts b/apps/api/src/app/journal/journal.service.ts new file mode 100644 index 000000000..eb6d753a6 --- /dev/null +++ b/apps/api/src/app/journal/journal.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + }; + } +} diff --git a/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html new file mode 100644 index 000000000..118aac886 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html @@ -0,0 +1,123 @@ + + +
+ +
+
+
Portfolio Change
+ +
+ + @if (data.realizedProfit !== 0) { +
+
Dividend & Interest
+ +
+ } + +
+
Activities
+ +
+
+ + + @if (activities.length > 0) { +

Activities

+
+ + + + + + + + + + + + @for (activity of activities; track activity.id) { + + + + + + + + } + +
TypeSymbolQuantityUnit PriceValue
+ + {{ activity.type }} + + {{ activity.SymbolProfile?.symbol ?? '-' }}{{ activity.quantity | number }} + + + +
+
+ } + + +

Journal Note

+ + Write your thoughts, observations, or strategy notes for this + day... + + + +
+ +
+
+ + diff --git a/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss new file mode 100644 index 000000000..366691a88 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss @@ -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); + } + } +} diff --git a/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts new file mode 100644 index 000000000..590b024f0 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts @@ -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(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData, + private dataService: DataService, + public dialogRef: MatDialogRef, + 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(); + } + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts b/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts new file mode 100644 index 000000000..e62b03856 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts @@ -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(); + + 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(); + } + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/journal/journal-page.html b/apps/client/src/app/pages/portfolio/journal/journal-page.html new file mode 100644 index 000000000..ebf91f09c --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.html @@ -0,0 +1,21 @@ +@if (isLoading) { +
+ +
+} @else { +
+ +
+} diff --git a/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts b/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts new file mode 100644 index 000000000..744c45f42 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts @@ -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 + } +]; diff --git a/apps/client/src/app/pages/portfolio/journal/journal-page.scss b/apps/client/src/app/pages/portfolio/journal/journal-page.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts index 387e3fd0f..a13bceda3 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts @@ -8,6 +8,7 @@ import { RouterModule } from '@angular/router'; import { addIcons } from 'ionicons'; import { analyticsOutline, + calendarOutline, calculatorOutline, pieChartOutline, scanOutline, @@ -58,6 +59,11 @@ export class PortfolioPageComponent implements OnDestroy, OnInit { routerLink: internalRoutes.portfolio.subRoutes.allocations.routerLink }, + { + iconName: 'calendar-outline', + label: internalRoutes.portfolio.subRoutes.journal.title, + routerLink: internalRoutes.portfolio.subRoutes.journal.routerLink + }, { iconName: 'calculator-outline', label: internalRoutes.portfolio.subRoutes.fire.title, @@ -77,6 +83,7 @@ export class PortfolioPageComponent implements OnDestroy, OnInit { addIcons({ analyticsOutline, + calendarOutline, calculatorOutline, pieChartOutline, scanOutline, diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.routes.ts b/apps/client/src/app/pages/portfolio/portfolio-page.routes.ts index aeebf4a6b..5986148a0 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.routes.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.routes.ts @@ -29,6 +29,11 @@ export const routes: Routes = [ loadChildren: () => import('./fire/fire-page.routes').then((m) => m.routes) }, + { + path: internalRoutes.portfolio.subRoutes.journal.path, + loadChildren: () => + import('./journal/journal-page.routes').then((m) => m.routes) + }, { path: internalRoutes.portfolio.subRoutes.xRay.path, loadChildren: () => diff --git a/libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts b/libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts new file mode 100644 index 000000000..d12c3265b --- /dev/null +++ b/libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CreateOrUpdateJournalEntryDto { + @IsNotEmpty() + @IsString() + @MaxLength(10000) + note: string; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index 3631d6eae..042ce9700 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -5,6 +5,7 @@ import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto import { CreateAccountDto } from './create-account.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileDto } from './create-asset-profile.dto'; +import { CreateOrUpdateJournalEntryDto } from './create-or-update-journal-entry.dto'; import { CreateOrderDto } from './create-order.dto'; import { CreatePlatformDto } from './create-platform.dto'; import { CreateTagDto } from './create-tag.dto'; @@ -31,6 +32,7 @@ export { CreateAccountWithBalancesDto, CreateAssetProfileDto, CreateAssetProfileWithMarketDataDto, + CreateOrUpdateJournalEntryDto, CreateOrderDto, CreatePlatformDto, CreateTagDto, diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ad747d94e..274b60f04 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -63,6 +63,12 @@ import type { ExportResponse } from './responses/export-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; import type { InfoResponse } from './responses/info-response.interface'; +import type { + JournalCalendarDataItem, + JournalEntryItem, + JournalResponse, + JournalStats +} from './responses/journal-response.interface'; import type { LookupResponse } from './responses/lookup-response.interface'; import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface'; import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; @@ -148,6 +154,10 @@ export { Holding, HoldingWithParents, ImportResponse, + JournalCalendarDataItem, + JournalEntryItem, + JournalResponse, + JournalStats, InfoItem, InfoResponse, InvestmentItem, diff --git a/libs/common/src/lib/interfaces/responses/journal-response.interface.ts b/libs/common/src/lib/interfaces/responses/journal-response.interface.ts new file mode 100644 index 000000000..ed1f17ec1 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/journal-response.interface.ts @@ -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; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index cb4eb175b..41fb1220a 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -10,6 +10,7 @@ export const permissions = { createAccount: 'createAccount', createAccountBalance: 'createAccountBalance', createApiKey: 'createApiKey', + createJournalEntry: 'createJournalEntry', createMarketData: 'createMarketData', createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createOrder: 'createOrder', @@ -22,6 +23,7 @@ export const permissions = { deleteAccount: 'deleteAccount', deleteAccountBalance: 'deleteAccountBalance', deleteAuthDevice: 'deleteAuthDevice', + deleteJournalEntry: 'deleteJournalEntry', deleteOrder: 'deleteOrder', deleteOwnUser: 'deleteOwnUser', deletePlatform: 'deletePlatform', @@ -41,6 +43,7 @@ export const permissions = { enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', readAiPrompt: 'readAiPrompt', + readJournalEntry: 'readJournalEntry', readMarketData: 'readMarketData', readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', @@ -54,6 +57,7 @@ export const permissions = { updateAccount: 'updateAccount', updateAccess: 'updateAccess', updateAuthDevice: 'updateAuthDevice', + updateJournalEntry: 'updateJournalEntry', updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateOrder: 'updateOrder', @@ -74,8 +78,10 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, + permissions.createJournalEntry, permissions.createWatchlistItem, permissions.deleteAccountBalance, + permissions.deleteJournalEntry, permissions.deleteWatchlistItem, permissions.createMarketData, permissions.createMarketDataOfOwnAssetProfile, @@ -91,6 +97,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteTag, permissions.deleteUser, permissions.readAiPrompt, + permissions.readJournalEntry, permissions.readMarketData, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, @@ -100,6 +107,7 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccount, permissions.updateAccess, permissions.updateAuthDevice, + permissions.updateJournalEntry, permissions.updateMarketData, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, @@ -125,6 +133,7 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, + permissions.createJournalEntry, permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, permissions.createOwnTag, @@ -133,15 +142,18 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteAuthDevice, + permissions.deleteJournalEntry, permissions.deleteOrder, permissions.deleteWatchlistItem, permissions.readAiPrompt, + permissions.readJournalEntry, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readWatchlist, permissions.updateAccount, permissions.updateAccess, permissions.updateAuthDevice, + permissions.updateJournalEntry, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, permissions.updateUserSettings, diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 53ecd104e..bd80b1050 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -142,6 +142,11 @@ export const internalRoutes: Record = { routerLink: ['/portfolio', 'fire'], title: 'FIRE' }, + journal: { + path: 'journal', + routerLink: ['/portfolio', 'journal'], + title: $localize`Journal` + }, xRay: { path: 'x-ray', routerLink: ['/portfolio', 'x-ray'], diff --git a/libs/ui/src/lib/journal-calendar/index.ts b/libs/ui/src/lib/journal-calendar/index.ts new file mode 100644 index 000000000..837ff38b2 --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/index.ts @@ -0,0 +1 @@ +export * from './journal-calendar.component'; diff --git a/libs/ui/src/lib/journal-calendar/journal-calendar.component.html b/libs/ui/src/lib/journal-calendar/journal-calendar.component.html new file mode 100644 index 000000000..7b7e52e08 --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.html @@ -0,0 +1,129 @@ +
+ +
+ +

{{ monthLabel }}

+ +
+ + + @if (stats) { +
+ + +
{{ stats.totalTradingDays }}
+
Trading Days
+
+
+ + +
{{ stats.winningDays }}
+
Winning Days
+
+
+ + +
{{ stats.losingDays }}
+
Losing Days
+
+
+ + +
{{ winRate }}%
+
Win Rate
+
+
+ + +
+ {{ formatCurrency(stats.averageDailyPnL) }} +
+
Avg Daily P&L
+
+
+
+ + +
+ + +
+ {{ formatCurrency(stats.largestProfit) }} +
+
Largest Profit
+
+
+ + +
+ {{ formatCurrency(stats.largestLoss) }} +
+
Largest Loss
+
+
+ + +
+ {{ stats.maxConsecutiveWins }} +
+
Max Consecutive Wins
+
+
+ + +
+ {{ stats.maxConsecutiveLosses }} +
+
Max Consecutive Losses
+
+
+
+ } + + +
+
+ @for (weekDay of weekDays; track weekDay) { +
{{ weekDay }}
+ } +
+ + @for (week of calendarWeeks; track $index) { +
+ @for (day of week; track day.date) { +
+
{{ day.dayOfMonth }}
+ @if (day.data && day.isCurrentMonth) { +
+ {{ formatCurrency(day.data.netPerformance) }} +
+ @if (day.data.activitiesCount > 0) { +
+ {day.data.activitiesCount, plural, + =1 {1 activity} + other {{{day.data.activitiesCount}} activities} + } +
+ } + @if (day.data.hasNote) { + + } + } +
+ } +
+ } +
+
diff --git a/libs/ui/src/lib/journal-calendar/journal-calendar.component.scss b/libs/ui/src/lib/journal-calendar/journal-calendar.component.scss new file mode 100644 index 000000000..8694c9048 --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.scss @@ -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); + } + } +} diff --git a/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts b/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts new file mode 100644 index 000000000..24c62b8ae --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts @@ -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(); + @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(); + + 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)); + } + } +} diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 37443cd20..23d5cb686 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -37,6 +37,8 @@ import { ExportResponse, Filter, ImportResponse, + JournalEntryItem, + JournalResponse, InfoItem, LookupResponse, MarketDataDetailsResponse, @@ -417,6 +419,26 @@ export class DataService { }); } + public fetchJournal({ month, year }: { month: number; year: number }) { + const params = new HttpParams() + .set('month', month.toString()) + .set('year', year.toString()); + + return this.http.get('/api/v1/journal', { params }); + } + + public fetchJournalEntry({ date }: { date: string }) { + return this.http.get(`/api/v1/journal/${date}`); + } + + public putJournalEntry({ date, note }: { date: string; note: string }) { + return this.http.put(`/api/v1/journal/${date}`, { note }); + } + + public deleteJournalEntry({ date }: { date: string }) { + return this.http.delete(`/api/v1/journal/${date}`); + } + public fetchHoldingDetail({ dataSource, symbol diff --git a/prisma/migrations/20260214000000_added_journal_entry/migration.sql b/prisma/migrations/20260214000000_added_journal_entry/migration.sql new file mode 100644 index 000000000..9f94c3956 --- /dev/null +++ b/prisma/migrations/20260214000000_added_journal_entry/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 232dde9ca..6e9a7c693 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,20 @@ model MarketData { @@index([symbol]) } +model JournalEntry { + createdAt DateTime @default(now()) + date DateTime + id String @id @default(uuid()) + note String + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], onDelete: Cascade, references: [id]) + userId String + + @@unique([userId, date]) + @@index([date]) + @@index([userId]) +} + model Order { account Account? @relation(fields: [accountId, accountUserId], references: [id, userId]) accountId String? @@ -259,17 +273,18 @@ model Tag { } model User { - accessesGet Access[] @relation("accessGet") - accessesGive Access[] @relation("accessGive") - accessToken String? - accounts Account[] - activities Order[] - analytics Analytics? - apiKeys ApiKey[] - authChallenge String? - authDevices AuthDevice[] - createdAt DateTime @default(now()) - id String @id @default(uuid()) + accessesGet Access[] @relation("accessGet") + accessesGive Access[] @relation("accessGive") + accessToken String? + accounts Account[] + activities Order[] + analytics Analytics? + apiKeys ApiKey[] + authChallenge String? + authDevices AuthDevice[] + createdAt DateTime @default(now()) + id String @id @default(uuid()) + journalEntries JournalEntry[] provider Provider @default(ANONYMOUS) role Role @default(USER) settings Settings?